v0.146.0
  1import logging
  2from abc import ABC, abstractmethod
  3from functools import cached_property
  4from typing import Any
  5
  6from opentelemetry import trace
  7from opentelemetry.semconv._incubating.attributes.feature_flag_attributes import (
  8    FEATURE_FLAG_KEY,
  9    FEATURE_FLAG_PROVIDER_NAME,
 10    FEATURE_FLAG_RESULT_REASON,
 11    FEATURE_FLAG_RESULT_VALUE,
 12    FeatureFlagResultReasonValues,
 13)
 14
 15from plain.runtime import settings
 16from plain.utils import timezone
 17
 18from . import exceptions
 19from .utils import coerce_key
 20
 21logger = logging.getLogger(__name__)
 22tracer = trace.get_tracer("plain.flags")
 23
 24
 25class Flag(ABC):
 26    @abstractmethod
 27    def get_key(self) -> Any:
 28        """
 29        Determine a unique key for this instance of the flag.
 30        This should be a quick operation, as it will be called on every use of the flag.
 31
 32        For convenience, you can return an instance of a Plain Model
 33        and it will be converted to a string automatically.
 34
 35        Return a falsy value if you don't want to store the flag result.
 36        """
 37        ...
 38
 39    @abstractmethod
 40    def get_value(self) -> Any:
 41        """
 42        Compute the resulting value of the flag.
 43
 44        The value needs to be JSON serializable.
 45
 46        If get_key() returns a value, this will only be called once per key
 47        and then subsequent calls will return the saved value from the DB.
 48        """
 49        ...
 50
 51    def get_db_name(self) -> str:
 52        """
 53        Should basically always be the name of the class.
 54        But this is overridable in case of renaming/refactoring/importing.
 55        """
 56        return self.__class__.__name__
 57
 58    def retrieve_or_compute_value(self) -> Any:
 59        """
 60        Retrieve the value from the DB if it exists,
 61        otherwise compute the value and save it to the DB.
 62        """
 63        from .models import Flag, FlagResult  # So Plain app is ready...
 64
 65        flag_name = self.get_db_name()
 66
 67        with tracer.start_as_current_span(
 68            f"flag {flag_name}",
 69            attributes={
 70                FEATURE_FLAG_PROVIDER_NAME: "plain.flags",
 71            },
 72        ) as span:
 73            # Resolve the key first so it's set on the span regardless of
 74            # which path we take below — including the disabled path, where
 75            # dashboards filtering by key still need to see the evaluation.
 76            key = self.get_key()
 77            if key:
 78                key = coerce_key(key)
 79                span.set_attribute(FEATURE_FLAG_KEY, key)
 80
 81            # Create an associated DB Flag that we can use to enable/disable
 82            # and tie the results to
 83            flag_obj, _ = Flag.query.update_or_create(
 84                name=flag_name,
 85                defaults={"used_at": timezone.now()},
 86            )
 87
 88            if not flag_obj.enabled:
 89                msg = f"The {flag_obj} flag has been disabled and should either not be called, or be re-enabled."
 90                span.set_attribute(
 91                    FEATURE_FLAG_RESULT_REASON,
 92                    FeatureFlagResultReasonValues.DISABLED.value,
 93                )
 94
 95                if settings.DEBUG:
 96                    raise exceptions.FlagDisabled(msg)
 97                else:
 98                    logger.exception(msg)
 99                    # Might not be the type of return value expected! Better than totally crashing now though.
100                    return None
101
102            if not key:
103                # No key, so we always recompute the value and return it
104                value = self.get_value()
105
106                span.set_attribute(
107                    FEATURE_FLAG_RESULT_REASON,
108                    FeatureFlagResultReasonValues.TARGETING_MATCH.value,
109                )
110                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
111
112                return value
113
114            try:
115                flag_result = FlagResult.query.get(flag=flag_obj, key=key)
116
117                span.set_attribute(
118                    FEATURE_FLAG_RESULT_REASON,
119                    FeatureFlagResultReasonValues.CACHED.value,
120                )
121                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(flag_result.value))
122
123                return flag_result.value
124            except FlagResult.DoesNotExist:
125                value = self.get_value()
126                flag_result = FlagResult.query.create(
127                    flag=flag_obj, key=key, value=value
128                )
129
130                # Per OTel semconv, `targeting_match` is "dynamic evaluation,
131                # such as a rule or specific user-targeting" — `get_value()`
132                # ran with this key. `static` would mean "no dynamic
133                # evaluation," which doesn't apply here.
134                span.set_attribute(
135                    FEATURE_FLAG_RESULT_REASON,
136                    FeatureFlagResultReasonValues.TARGETING_MATCH.value,
137                )
138                span.set_attribute(FEATURE_FLAG_RESULT_VALUE, str(value))
139
140                return flag_result.value
141
142    @cached_property
143    def value(self) -> Any:
144        """
145        Cached version of retrieve_or_compute_value()
146        """
147        return self.retrieve_or_compute_value()
148
149    def __bool__(self) -> bool:
150        """
151        Allow for use in boolean expressions.
152        """
153        return bool(self.value)
154
155    def __contains__(self, item: Any) -> bool:
156        """
157        Allow for use in `in` expressions.
158        """
159        return item in self.value
160
161    def __eq__(self, other: object) -> bool:
162        """
163        Allow for use in `==` expressions.
164        """
165        return self.value == other