diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 687ec41..dfc7e44 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -37,3 +37,28 @@ jobs: - name: Run Tests run: poetry run pytest + + test-sdk-floor: + runs-on: ubuntu-latest + name: Pytest against the minimum supported flagsmith version + + steps: + - name: Cloning repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry install --no-root + poetry run pip install flagsmith==5.5.0 + + - name: Run Tests + run: poetry run pytest diff --git a/README.md b/README.md index a1c6e08..daa82a4 100644 --- a/README.md +++ b/README.md @@ -60,40 +60,85 @@ provider = FlagsmithProvider( The provider can then be used with the OpenFeature client as per [the documentation](https://openfeature.dev/docs/reference/concepts/evaluation-api#setting-a-provider). -### Tracking +### Tracking and experimentation -The provider supports the [OpenFeature tracking API](https://openfeature.dev/specification/sections/tracking/), which lets you associate user actions with feature flag evaluations for experimentation. +The provider supports the [OpenFeature tracking API](https://openfeature.dev/specification/sections/tracking/) (an experimental OpenFeature capability), which lets you record custom events and flag **exposures** for experimentation. -Tracking requires pipeline analytics to be enabled on the **Flagsmith client** (available from `flagsmith` version 5.2.0). The provider acts as a thin delegate — all buffering and flushing is managed by the client. +Tracking requires events to be enabled on the **Flagsmith client** (`flagsmith` ≥5.5). The provider acts as a thin delegate — all buffering and flushing is managed by the client. ```python -from flagsmith import Flagsmith, PipelineAnalyticsConfig +from flagsmith import Flagsmith from openfeature import api -from openfeature.evaluation_context import EvaluationContext -from openfeature.track import TrackingEventDetails -from openfeature_flagsmith.provider import FlagsmithProvider +from openfeature_flagsmith import FlagsmithProvider -# Enable pipeline analytics on the Flagsmith client client = Flagsmith( environment_key="your-environment-key", - pipeline_analytics_config=PipelineAnalyticsConfig( - analytics_server_url="https://analytics-collector.flagsmith.com/", - max_buffer_items=1000, # optional, default 1000 - flush_interval_seconds=10, # optional, default 10s - ), + enable_events=True, ) -api.set_provider(FlagsmithProvider(client=client)) +provider = FlagsmithProvider(client=client) +api.set_provider(provider) of_client = api.get_client() +``` + +If events are not enabled on the Flagsmith client, all tracking calls are silently dropped. + +#### Recording exposures + +An **exposure** marks an identity as having experienced an experiment variant. Exposures are never recorded automatically: evaluating a flag does not expose anyone. There are three ways to record them, from most to least recommended. + +**1. The exposure hook (recommended).** Attach `FlagsmithExposureHook` to the evaluations that *are* your experiment — attaching the hook is the experiment declaration: -# Flag evaluations are tracked automatically — no extra code needed -variant = of_client.get_string_value( - "checkout-variant", +```python +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationOptions +from openfeature_flagsmith import FlagsmithExposureHook + +hook = FlagsmithExposureHook(provider) + +details = of_client.get_string_details( + "my_experiment_flag", "control", + EvaluationContext(targeting_key="user-123"), + FlagEvaluationOptions(hooks=[hook]), +) +``` + +The hook records an exposure only when the flag resolved with a variant and reason `SPLIT` — a multivariate percentage-split assignment (enabled, identified, not offline). Repeated evaluations are safe: duplicate exposures are deduplicated downstream. + +**2. Explicit `track()`.** Use the reserved `feature_flag.exposure` event name when you need to record an exposure decoupled from evaluation: + +```python +from openfeature.track import TrackingEventDetails +from openfeature_flagsmith import EXPOSURE_TRACKING_EVENT + +# With an explicit variant: sent as rendered. +of_client.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-123"), + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "my_experiment_flag", "variant": "treatment"} + ), +) + +# Without a variant: the provider resolves the flag for the targeting key and +# records the exposure only if the flag exists, is enabled and has a variant. +of_client.track( + EXPOSURE_TRACKING_EVENT, evaluation_context=EvaluationContext(targeting_key="user-123"), + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "my_experiment_flag"} + ), ) +``` + +**3. The native Flagsmith client.** `client.get_experiment_flag(...)` / `client.track_exposure_event(...)` work as documented in the [Flagsmith docs](https://docs.flagsmith.com/) and share the same event pipeline. -# Track a custom event explicitly +#### Custom events + +Any other event name is forwarded as a plain Flagsmith event. `TrackingEventDetails.value` must be numeric and is sent as the event value; `attributes` become event metadata; context traits are attached to the event. + +```python of_client.track( "purchase", evaluation_context=EvaluationContext( @@ -107,7 +152,11 @@ of_client.track( ) ``` -If `pipeline_analytics_config` is not set on the Flagsmith client, calls to `track()` are silently ignored. +#### Caveats + +- **Anonymous contexts**: exposures require a `targeting_key`; without one they are skipped (logged at info). +- **Reserved names**: event names starting with `$` are reserved for Flagsmith system events and are dropped with a warning — use `EXPOSURE_TRACKING_EVENT` to record exposures. +- **Transient identities** (Python provider only, remote evaluation only): set the context attribute `"transient": True` to evaluate an identity without persisting it. The variant-less exposure path honors it too. ### Evaluation Context diff --git a/openfeature_flagsmith/__init__.py b/openfeature_flagsmith/__init__.py index e69de29..fcf5e72 100644 --- a/openfeature_flagsmith/__init__.py +++ b/openfeature_flagsmith/__init__.py @@ -0,0 +1,9 @@ +from openfeature_flagsmith.hooks import FlagsmithExposureHook +from openfeature_flagsmith.provider import FlagsmithProvider +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT + +__all__ = [ + "EXPOSURE_TRACKING_EVENT", + "FlagsmithExposureHook", + "FlagsmithProvider", +] diff --git a/openfeature_flagsmith/hooks.py b/openfeature_flagsmith/hooks.py new file mode 100644 index 0000000..6f64b15 --- /dev/null +++ b/openfeature_flagsmith/hooks.py @@ -0,0 +1,75 @@ +import logging +import typing + +from openfeature.flag_evaluation import FlagEvaluationDetails, Reason +from openfeature.hook import Hook, HookContext, HookHints +from openfeature.track import TrackingEventDetails + +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT + +if typing.TYPE_CHECKING: + from openfeature_flagsmith.provider import FlagsmithProvider + +logger = logging.getLogger(__name__) + + +def _is_split_reason(reason: typing.Union[str, Reason, None]) -> bool: + # The engine annotates reasons ("SPLIT; weight=30"); compare the + # leading token. + if reason is None: + return False + return str(reason).split(";", 1)[0].strip() == Reason.SPLIT.value + + +class FlagsmithExposureHook(Hook): + """ + Records a Flagsmith exposure as a side effect of a flag evaluation:: + + hook = FlagsmithExposureHook(provider) + client.get_string_details( + "my_experiment_flag", + "control", + context, + FlagEvaluationOptions(hooks=[hook]), + ) + + Attaching the hook at a call site is the experiment declaration: + evaluations without it never record exposures. Exposures only fire for + flags resolved with a variant and reason ``SPLIT``; duplicate exposures + are deduplicated downstream. + """ + + def __init__(self, provider: "FlagsmithProvider") -> None: + self._provider = provider + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails, + hints: HookHints, + ) -> None: + # An uncaught after-hook error would flip the evaluation to ERROR. + try: + variant = details.variant + if not isinstance(variant, str): + return + if not _is_split_reason(details.reason): + logger.debug( + 'Exposure for "%s" skipped: resolution reason is %s, not SPLIT.', + details.flag_key, + details.reason, + ) + return + self._provider.track( + EXPOSURE_TRACKING_EVENT, + hook_context.evaluation_context, + TrackingEventDetails( + attributes={"flag_key": details.flag_key, "variant": variant} + ), + ) + except Exception: + logger.warning( + 'Failed to record the exposure for "%s".', + details.flag_key, + exc_info=True, + ) diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index 9e075ae..3782432 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -1,9 +1,14 @@ import json +import logging import typing from json import JSONDecodeError -from flagsmith.exceptions import FlagsmithClientError +from flagsmith.exceptions import ( + FlagsmithClientError, + FlagsmithFeatureDoesNotExistError, +) from flagsmith.flagsmith import Flagsmith +from flagsmith.models import Flag from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ( ErrorCode, @@ -11,11 +16,14 @@ ParseError, TypeMismatchError, ) -from openfeature.flag_evaluation import FlagResolutionDetails, FlagType +from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason from openfeature.provider import AbstractProvider, Metadata from openfeature.track import TrackingEventDetails from openfeature_flagsmith.exceptions import FlagsmithProviderError +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT + +logger = logging.getLogger(__name__) _BASIC_FLAG_TYPE_MAPPINGS = { FlagType.BOOLEAN: bool, @@ -25,17 +33,6 @@ } -class TrackingMetadata(typing.TypedDict, total=False): - """ - Shape of the metadata dict forwarded to ``Flagsmith.track_event``. - - ``value`` holds the numeric value from ``TrackingEventDetails.value`` when - set. All other keys pass through from ``TrackingEventDetails.attributes``. - """ - - value: float - - class FlagsmithProvider(AbstractProvider): def __init__( self, @@ -56,42 +53,169 @@ def track( tracking_event_details: typing.Optional[TrackingEventDetails] = None, ) -> None: """ - Records a custom event via the Flagsmith client's pipeline analytics. + Route OpenFeature tracking events to Flagsmith. - No-ops if the client lacks pipeline analytics support or configuration. - An explicit ``tracking_event_details.value`` overrides any same-named - key in ``attributes``. + ``EXPOSURE_TRACKING_EVENT`` records a flag/variant exposure; any other + name becomes a plain Flagsmith event. No-ops unless the client was + initialized with ``enable_events``. Never raises: errors are logged. """ - # Guard against older flagsmith versions or duck-typed clients - # that don't have track_event. - if not hasattr(self._client, "track_event"): + try: + self._track(tracking_event_name, evaluation_context, tracking_event_details) + except Exception: + logger.warning( + 'Failed to process tracking event "%s".', + tracking_event_name, + exc_info=True, + ) + + def _track( + self, + tracking_event_name: str, + evaluation_context: typing.Optional[EvaluationContext], + tracking_event_details: typing.Optional[TrackingEventDetails], + ) -> None: + # The SDK has no public events-enabled signal; check first so + # disabled events cause no network side effects. + if getattr(self._client, "_event_processor", None) is None: + logger.debug( + 'Flagsmith events are disabled; dropping tracking event "%s".', + tracking_event_name, + ) return identifier = evaluation_context.targeting_key if evaluation_context else None traits = self._extract_traits(evaluation_context) - metadata: typing.Optional[TrackingMetadata] = None - if tracking_event_details is not None: - metadata = typing.cast( - TrackingMetadata, dict(tracking_event_details.attributes) + if tracking_event_name == EXPOSURE_TRACKING_EVENT: + self._track_exposure( + identifier, traits, evaluation_context, tracking_event_details + ) + return + + if tracking_event_name.startswith("$"): + logger.warning( + '"%s" is a reserved Flagsmith event name; use "%s" to record' + " exposures.", + tracking_event_name, + EXPOSURE_TRACKING_EVENT, ) - if tracking_event_details.value is not None: - metadata["value"] = tracking_event_details.value - if not metadata: - metadata = None + return + + value = tracking_event_details.value if tracking_event_details else None + attributes = ( + dict(tracking_event_details.attributes) if tracking_event_details else {} + ) + if value is not None and not isinstance(value, (int, float)): + logger.warning( + 'Tracking event "%s" details.value must be numeric;' + " sending without it.", + tracking_event_name, + ) + value = None try: self._client.track_event( tracking_event_name, - identity_identifier=identifier, + identifier=identifier, + value=value, traits=traits, - metadata=metadata, + metadata=attributes or None, ) except ValueError: - # Flagsmith raises ValueError when pipeline analytics is not - # configured; OpenFeature spec requires track() to no-op. + # Events disabled (racing the check above) or name rejected. + logger.debug( + 'Flagsmith rejected tracking event "%s"; dropping it.', + tracking_event_name, + exc_info=True, + ) + + def _track_exposure( + self, + identifier: typing.Optional[str], + traits: typing.Optional[typing.Dict[str, typing.Any]], + evaluation_context: typing.Optional[EvaluationContext], + tracking_event_details: typing.Optional[TrackingEventDetails], + ) -> None: + attributes = ( + dict(tracking_event_details.attributes) if tracking_event_details else {} + ) + flag_key = attributes.pop("flag_key", None) + variant = attributes.pop("variant", None) + metadata = attributes or None + + if not isinstance(flag_key, str): + logger.warning( + '"%s" requires a string "flag_key" attribute; dropping exposure' + " event.", + EXPOSURE_TRACKING_EVENT, + ) + return + if variant is not None and not isinstance(variant, str): + logger.warning( + '"%s" requires a string "variant" attribute when provided;' + " dropping exposure event.", + EXPOSURE_TRACKING_EVENT, + ) + return + if not identifier: + logger.info( + 'Exposure for "%s" skipped: no targeting_key in the evaluation' + " context.", + flag_key, + ) return + if isinstance(variant, str): + self._client.track_exposure_event( + feature_name=flag_key, + identifier=identifier, + value=variant, + traits=traits, + metadata=metadata, + ) + return + + # Mirrors the SDK's get_experiment_flag guards, attributed to the + # context's targeting key rather than any ambient identity. + try: + flag = self._client.get_identity_flags( + identifier=identifier, + traits=traits or {}, + transient=self._is_transient(evaluation_context), + ).get_flag(flag_key) + except FlagsmithFeatureDoesNotExistError: + logger.info('Exposure for "%s" skipped: flag does not exist.', flag_key) + return + except FlagsmithClientError: + logger.warning( + 'Exposure for "%s" skipped: failed to resolve the flag.', + flag_key, + exc_info=True, + ) + return + + if not isinstance(flag, Flag): + logger.info('Exposure for "%s" skipped: flag does not exist.', flag_key) + return + if not flag.enabled: + logger.info('Exposure for "%s" skipped: flag is disabled.', flag_key) + return + if flag.variant is None: + logger.info( + 'Exposure for "%s" skipped: experiments require an enabled' + " multivariate flag.", + flag_key, + ) + return + + self._client.track_exposure_event( + feature_name=flag_key, + identifier=identifier, + value=flag.variant, + traits=traits, + metadata=metadata, + ) + def get_metadata(self) -> Metadata: return Metadata(name="FlagsmithProvider") @@ -164,7 +288,7 @@ def _resolve( raise FlagNotFoundError(error_message="Flag '%s' was not found." % flag_key) if flag_type == FlagType.BOOLEAN and not self.use_boolean_config_value: - return FlagResolutionDetails(value=flag.enabled) + return self._build_details(flag, flag.enabled, evaluation_context) if not (self.return_value_for_disabled_flags or flag.enabled): raise FlagsmithProviderError( @@ -174,10 +298,12 @@ def _resolve( required_type = _BASIC_FLAG_TYPE_MAPPINGS.get(flag_type) if required_type and isinstance(flag.value, required_type): - return FlagResolutionDetails(value=flag.value) + return self._build_details(flag, flag.value, evaluation_context) elif flag_type is FlagType.OBJECT and isinstance(flag.value, str): try: - return FlagResolutionDetails(value=json.loads(flag.value)) + return self._build_details( + flag, json.loads(flag.value), evaluation_context + ) except JSONDecodeError as e: msg = "Unable to parse object from value for flag '%s'" % flag_key raise ParseError(error_message=msg) from e @@ -187,6 +313,54 @@ def _resolve( % (flag_key, flag_type.value) ) + def _build_details( + self, + flag: typing.Any, + value: typing.Any, + evaluation_context: EvaluationContext, + ) -> FlagResolutionDetails: + return FlagResolutionDetails( + value=value, + reason=self._parse_reason(flag, evaluation_context), + # DefaultFlag has no `variant` attribute. + variant=getattr(flag, "variant", None), + flag_metadata=self._build_flag_metadata(flag), + ) + + def _parse_reason( + self, flag: typing.Any, evaluation_context: EvaluationContext + ) -> Reason: + if flag.is_default: + return Reason.DEFAULT + if not flag.enabled: + return Reason.DISABLED + # Offline documents may be arbitrarily old. + if getattr(self._client, "offline_mode", False): + return Reason.STALE + if evaluation_context.targeting_key: + # A variant means a percentage-split assignment (SPLIT); + # TARGETING_MATCH is reserved for segment matches. + if getattr(flag, "variant", None) is not None: + return Reason.SPLIT + return Reason.TARGETING_MATCH + return Reason.STATIC + + def _build_flag_metadata( + self, flag: typing.Any + ) -> typing.Dict[str, typing.Union[bool, int, str]]: + # Keys are byte-identical with the JS provider. + metadata: typing.Dict[str, typing.Union[bool, int, str]] = { + "enabled": flag.enabled + } + if isinstance(flag, Flag): + metadata["featureId"] = flag.feature_id + variant = getattr(flag, "variant", None) + if variant is not None: + metadata["experiment.arm"] = variant + metadata["experiment.active"] = flag.enabled + metadata["experiment.unit"] = "user" + return metadata + @staticmethod def _extract_traits( evaluation_context: typing.Optional[EvaluationContext], @@ -194,14 +368,30 @@ def _extract_traits( if not evaluation_context or not evaluation_context.attributes: return None nested = evaluation_context.attributes.get("traits", {}) - flat = {k: v for k, v in evaluation_context.attributes.items() if k != "traits"} + # The flat `transient` key is an evaluation directive, not a trait. + flat = { + k: v + for k, v in evaluation_context.attributes.items() + if k not in ("traits", "transient") + } merged = {**flat, **nested} return merged or None + @staticmethod + def _is_transient( + evaluation_context: typing.Optional[EvaluationContext], + ) -> bool: + return bool( + evaluation_context + and evaluation_context.attributes + and evaluation_context.attributes.get("transient") is True + ) + def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()): if targeting_key := evaluation_context.targeting_key: return self._client.get_identity_flags( identifier=targeting_key, traits=self._extract_traits(evaluation_context) or {}, + transient=self._is_transient(evaluation_context), ) return self._client.get_environment_flags() diff --git a/openfeature_flagsmith/tracking.py b/openfeature_flagsmith/tracking.py new file mode 100644 index 0000000..9821ae0 --- /dev/null +++ b/openfeature_flagsmith/tracking.py @@ -0,0 +1,9 @@ +import typing + +EXPOSURE_TRACKING_EVENT: typing.Final[str] = "feature_flag.exposure" +""" +Reserved tracking-event name for recording flag/variant exposures. + +OpenFeature-facing name, identical across Flagsmith providers; on the wire +the SDK emits the ``$flag_exposure`` system event. +""" diff --git a/poetry.lock b/poetry.lock index 7c276dd..753b30c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -184,18 +184,18 @@ typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] [[package]] name = "flagsmith" -version = "5.2.0" +version = "6.1.0" description = "Flagsmith Python SDK" optional = false -python-versions = "<4,>=3.9" +python-versions = "<4,>=3.10" groups = ["main"] files = [ - {file = "flagsmith-5.2.0-py3-none-any.whl", hash = "sha256:07114d9ccaa1206d13a995bcd99a96ea4c4b7bda8c731b1d023ca233189879cc"}, - {file = "flagsmith-5.2.0.tar.gz", hash = "sha256:734d6ea733586fed2d96714203f8fb4f997e0039f3a4966f02c51574a7786d68"}, + {file = "flagsmith-6.1.0-py3-none-any.whl", hash = "sha256:c1adf56cd5cabcaf3fdd1b0dad8d3fc6e621a96bc2703b88c98ed9da91ead792"}, + {file = "flagsmith-6.1.0.tar.gz", hash = "sha256:f62e74f3aa2220702a60edc76e1eb7ebca46a2967da938455efa4d1642364f46"}, ] [package.dependencies] -flagsmith-flag-engine = ">=10.0.3,<11.0.0" +flagsmith-flag-engine = ">=10.2.0,<11.0.0" iso8601 = {version = ">=2.1.0,<3.0.0", markers = "python_version < \"3.11\""} requests = ">=2.32.3,<3.0.0" requests-futures = ">=1.0.1,<2.0.0" @@ -204,14 +204,14 @@ typing-extensions = ">=4.15.0,<5.0.0" [[package]] name = "flagsmith-flag-engine" -version = "10.0.3" +version = "10.2.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main"] files = [ - {file = "flagsmith_flag_engine-10.0.3-py3-none-any.whl", hash = "sha256:aed9009377fc1a6322483277f971f06d542668a69d93cbe4a3efd4baae78dfc1"}, - {file = "flagsmith_flag_engine-10.0.3.tar.gz", hash = "sha256:0aa449bb87bee54fc67b5c7ca25eca78246a7bbb5a6cc229260c3f262d58ac54"}, + {file = "flagsmith_flag_engine-10.2.0-py3-none-any.whl", hash = "sha256:c9bed3ee15487057dc61144d34d101d98db255f17d2c739f02794841a5c98502"}, + {file = "flagsmith_flag_engine-10.2.0.tar.gz", hash = "sha256:d935c9fb639e8acc5b9ff4599ec570e1b2f3f7b7874fc789a6eca3db5665a31b"}, ] [package.dependencies] @@ -802,4 +802,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "59e0a22ab4299d665eebfd14d8a178a90c158c61a3b019194661a9f19d071942" +content-hash = "85101a6d38fa1b56f9a451a8340cb6cbc1e988f136545b472aef72fdae1ea359" diff --git a/pyproject.toml b/pyproject.toml index b68a085..a2c6355 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ readme = "README.md" requires-python = ">=3.10,<4.0" dependencies = [ - "flagsmith (>=5.2.0,<7.0.0)", + "flagsmith (>=5.5.0,<7.0.0)", "openfeature-sdk (>=0.9.0,<0.10.0)", ] diff --git a/tests/test_cross_sdk_contract.py b/tests/test_cross_sdk_contract.py new file mode 100644 index 0000000..8e39eda --- /dev/null +++ b/tests/test_cross_sdk_contract.py @@ -0,0 +1,100 @@ +"""Cross-SDK wire contract: these literals are shared with the JS provider +and the analytics pipeline — changing them here means changing them there.""" + +from unittest.mock import MagicMock, create_autospec + +from flagsmith import Flagsmith +from flagsmith.models import Flag, Flags +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason +from openfeature.hook import HookContext +from openfeature.track import TrackingEventDetails + +from openfeature_flagsmith.hooks import FlagsmithExposureHook +from openfeature_flagsmith.provider import FlagsmithProvider +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT + + +def test_exposure_tracking_event_name() -> None: + assert EXPOSURE_TRACKING_EVENT == "feature_flag.exposure" + + +def test_track_reads_snake_case_exposure_attribute_keys() -> None: + # Given + client = create_autospec(Flagsmith, instance=True) + client._event_processor = MagicMock() + provider = FlagsmithProvider(client) + + # When + provider.track( + "feature_flag.exposure", + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "exp", "variant": "arm-a"} + ), + ) + + # Then + client.track_exposure_event.assert_called_once_with( + feature_name="exp", + identifier="user-1", + value="arm-a", + traits=None, + metadata=None, + ) + + +def test_hook_emits_contract_attribute_keys() -> None: + # Given + provider = create_autospec(FlagsmithProvider, instance=True) + hook = FlagsmithExposureHook(provider) + + # When + hook.after( + hook_context=HookContext( + flag_key="exp", + flag_type=FlagType.STRING, + default_value="control", + evaluation_context=EvaluationContext(targeting_key="user-1"), + ), + details=FlagEvaluationDetails( + flag_key="exp", value="v", variant="arm-a", reason=Reason.SPLIT + ), + hints={}, + ) + + # Then + event_name, _, details = provider.track.call_args.args + assert event_name == "feature_flag.exposure" + assert details.attributes == {"flag_key": "exp", "variant": "arm-a"} + + +def test_experiment_flag_metadata_keys() -> None: + # Given + client = create_autospec(Flagsmith, instance=True) + client.get_identity_flags.return_value = Flags( + { + "exp": Flag( + feature_id=1, + feature_name="exp", + enabled=True, + value="v", + variant="arm-a", + ) + } + ) + provider = FlagsmithProvider(client) + + # When + result = provider.resolve_string_details( + "exp", "control", EvaluationContext(targeting_key="user-1") + ) + + # Then + assert result.flag_metadata == { + "enabled": True, + "featureId": 1, + "experiment.arm": "arm-a", + "experiment.active": True, + "experiment.unit": "user", + } diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..09969bf --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,157 @@ +from unittest.mock import MagicMock, create_autospec + +import pytest +from flagsmith import Flagsmith +from flagsmith.models import Flag, Flags +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import ( + FlagEvaluationDetails, + FlagEvaluationOptions, + FlagType, + Reason, +) +from openfeature.hook import HookContext + +from openfeature_flagsmith.hooks import FlagsmithExposureHook +from openfeature_flagsmith.provider import FlagsmithProvider +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT + + +@pytest.fixture() +def mock_provider() -> MagicMock: + return create_autospec(FlagsmithProvider, instance=True) + + +def _hook_context(targeting_key="user-1") -> HookContext: + return HookContext( + flag_key="my_exp", + flag_type=FlagType.STRING, + default_value="control", + evaluation_context=EvaluationContext(targeting_key=targeting_key), + ) + + +def _details( + flag_key="my_exp", variant="treatment", reason=Reason.SPLIT +) -> FlagEvaluationDetails: + return FlagEvaluationDetails( + flag_key=flag_key, value="v", variant=variant, reason=reason + ) + + +def test_hook_records_exposure_on_split(mock_provider: MagicMock) -> None: + # Given + hook = FlagsmithExposureHook(mock_provider) + context = _hook_context() + + # When + hook.after(hook_context=context, details=_details(), hints={}) + + # Then + mock_provider.track.assert_called_once() + name, of_context, details = mock_provider.track.call_args.args + assert name == EXPOSURE_TRACKING_EVENT + assert of_context is context.evaluation_context + assert details.attributes == {"flag_key": "my_exp", "variant": "treatment"} + + +def test_hook_skips_without_variant(mock_provider: MagicMock) -> None: + # Given + hook = FlagsmithExposureHook(mock_provider) + + # When + hook.after(hook_context=_hook_context(), details=_details(variant=None), hints={}) + + # Then + mock_provider.track.assert_not_called() + + +@pytest.mark.parametrize( + "reason", + [ + Reason.STATIC, + Reason.DEFAULT, + Reason.DISABLED, + Reason.STALE, + Reason.CACHED, + Reason.TARGETING_MATCH, + None, + "SPLITTER; weight=30", + ], +) +def test_hook_skips_on_non_split_reason(mock_provider: MagicMock, reason) -> None: + # Given + hook = FlagsmithExposureHook(mock_provider) + + # When + hook.after(hook_context=_hook_context(), details=_details(reason=reason), hints={}) + + # Then + mock_provider.track.assert_not_called() + + +@pytest.mark.parametrize("reason", ["SPLIT", "SPLIT; weight=30", "SPLIT ; seed=abc"]) +def test_hook_accepts_engine_annotated_split_reasons( + mock_provider: MagicMock, reason: str +) -> None: + # Given + hook = FlagsmithExposureHook(mock_provider) + + # When + hook.after(hook_context=_hook_context(), details=_details(reason=reason), hints={}) + + # Then + mock_provider.track.assert_called_once() + + +def test_hook_swallows_provider_errors(mock_provider: MagicMock) -> None: + # Given + mock_provider.track.side_effect = RuntimeError("boom") + hook = FlagsmithExposureHook(mock_provider) + + # When / Then + hook.after(hook_context=_hook_context(), details=_details(), hints={}) + + +def test_hook_end_to_end_records_exposure_through_openfeature() -> None: + # Given + client = create_autospec(Flagsmith, instance=True) + client._event_processor = MagicMock() + client.get_identity_flags.return_value = Flags( + { + "my_exp": Flag( + feature_id=1, + feature_name="my_exp", + enabled=True, + value="treatment-value", + variant="treatment", + ) + } + ) + provider = FlagsmithProvider(client) + api.set_provider(provider) + try: + of_client = api.get_client() + hook = FlagsmithExposureHook(provider) + + # When + details = of_client.get_string_details( + "my_exp", + "control", + EvaluationContext(targeting_key="user-1"), + FlagEvaluationOptions(hooks=[hook]), + ) + + # Then + assert details.value == "treatment-value" + assert details.variant == "treatment" + client.track_exposure_event.assert_called_once_with( + feature_name="my_exp", + identifier="user-1", + value="treatment", + traits=None, + metadata=None, + ) + finally: + api.clear_providers() diff --git a/tests/test_provider.py b/tests/test_provider.py index 1da313a..fc11da1 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import pytest from flagsmith import Flagsmith @@ -11,15 +11,23 @@ ParseError, FlagNotFoundError, ) +from openfeature.flag_evaluation import Reason from openfeature.track import TrackingEventDetails from openfeature_flagsmith.exceptions import FlagsmithProviderError from openfeature_flagsmith.provider import FlagsmithProvider +from openfeature_flagsmith.tracking import EXPOSURE_TRACKING_EVENT @pytest.fixture() -def mock_flagsmith_client() -> MagicMock(): - return MagicMock(spec=Flagsmith) +def mock_flagsmith_client() -> MagicMock: + return create_autospec(Flagsmith, instance=True) + + +@pytest.fixture() +def tracking_flagsmith_client(mock_flagsmith_client: MagicMock) -> MagicMock: + mock_flagsmith_client._event_processor = MagicMock() + return mock_flagsmith_client def test_get_metadata(mock_flagsmith_client: MagicMock) -> None: @@ -210,7 +218,7 @@ def test_resolve_string_details_when_not_enabled_and_return_value_for_disabled_f # Then assert result.value == value - assert result.reason is None + assert result.reason == Reason.DISABLED assert result.error_code is None @@ -258,7 +266,7 @@ def test_resolve_string_details_for_flagsmith_default_flag_when_use_flagsmith_de # Then assert result.value == value - assert result.reason is None + assert result.reason == Reason.DEFAULT assert result.error_code is None @@ -313,10 +321,10 @@ def test_identity_flags_are_used_if_targeting_key_provided( # Then assert result.value == value assert result.error_code is None - assert result.reason is None + assert result.reason == Reason.TARGETING_MATCH mock_flagsmith_client.get_identity_flags.assert_called_once_with( - identifier=targeting_key, traits=traits + identifier=targeting_key, traits=traits, transient=False ) @@ -349,10 +357,10 @@ def test_identity_flags_are_used_with_flat_attributes( # Then assert result.value == value assert result.error_code is None - assert result.reason is None + assert result.reason == Reason.TARGETING_MATCH mock_flagsmith_client.get_identity_flags.assert_called_once_with( - identifier=targeting_key, traits=traits + identifier=targeting_key, traits=traits, transient=False ) @@ -388,11 +396,12 @@ def test_identity_flags_flat_attributes_and_nested_traits_are_merged( # Then assert result.value == value assert result.error_code is None - assert result.reason is None + assert result.reason == Reason.TARGETING_MATCH mock_flagsmith_client.get_identity_flags.assert_called_once_with( identifier=targeting_key, traits={"flat_trait": "flat_value", "nested_trait": "nested_value"}, + transient=False, ) @@ -429,6 +438,7 @@ def test_identity_flags_nested_traits_take_precedence_over_flat_attributes( mock_flagsmith_client.get_identity_flags.assert_called_once_with( identifier=targeting_key, traits={"shared_key": "nested_value"}, + transient=False, ) @@ -450,40 +460,52 @@ def test_resolve_boolean_details_uses_enabled_when_use_boolean_config_value_is_f # Then assert result.value is True assert result.error_code is None - assert result.reason is None + assert result.reason == Reason.STATIC # --------------------------------------------------------------------------- -# Tracking +# Tracking: custom events # --------------------------------------------------------------------------- -def test_track_is_noop_without_track_event_on_client() -> None: - # Given - client without track_event (e.g. older flagsmith version) - client = MagicMock(spec=[]) - provider = FlagsmithProvider(client) +def test_track_is_noop_when_events_disabled( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(mock_flagsmith_client) - # When / Then - no error raised + # When provider.track("purchase") + # Then + mock_flagsmith_client.track_event.assert_not_called() -def test_track_is_noop_when_pipeline_analytics_not_configured( - mock_flagsmith_client: MagicMock, + +def test_track_swallows_value_error_from_sdk( + tracking_flagsmith_client: MagicMock, ) -> None: - # Given - client has track_event but raises ValueError (no analytics config) - mock_flagsmith_client.track_event = MagicMock( - side_effect=ValueError("Pipeline analytics is not configured") - ) - provider = FlagsmithProvider(mock_flagsmith_client) + # Given + tracking_flagsmith_client.track_event.side_effect = ValueError("events disabled") + provider = FlagsmithProvider(tracking_flagsmith_client) - # When / Then - no error raised, ValueError caught silently + # When / Then provider.track("purchase") -def test_track_delegates_to_client(mock_flagsmith_client: MagicMock) -> None: +def test_track_swallows_unexpected_exceptions( + tracking_flagsmith_client: MagicMock, +) -> None: # Given - mock_flagsmith_client.track_event = MagicMock() - provider = FlagsmithProvider(mock_flagsmith_client) + tracking_flagsmith_client.track_event.side_effect = RuntimeError("boom") + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When / Then + provider.track("purchase") + + +def test_track_delegates_to_client(tracking_flagsmith_client: MagicMock) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) # When provider.track( @@ -499,77 +521,84 @@ def test_track_delegates_to_client(mock_flagsmith_client: MagicMock) -> None: ) # Then - mock_flagsmith_client.track_event.assert_called_once_with( + tracking_flagsmith_client.track_event.assert_called_once_with( "purchase", - identity_identifier="user-123", + identifier="user-123", + value=99.77, traits={"plan": "premium"}, - metadata={"value": 99.77, "currency": "USD"}, + metadata={"currency": "USD"}, ) -def test_track_with_minimal_args(mock_flagsmith_client: MagicMock) -> None: +def test_track_with_minimal_args(tracking_flagsmith_client: MagicMock) -> None: # Given - mock_flagsmith_client.track_event = MagicMock() - provider = FlagsmithProvider(mock_flagsmith_client) + provider = FlagsmithProvider(tracking_flagsmith_client) # When provider.track("signup") # Then - mock_flagsmith_client.track_event.assert_called_once_with( + tracking_flagsmith_client.track_event.assert_called_once_with( "signup", - identity_identifier=None, + identifier=None, + value=None, traits=None, metadata=None, ) -def test_track_value_takes_precedence_over_attributes_value( - mock_flagsmith_client: MagicMock, +def test_track_attributes_pass_through_as_metadata( + tracking_flagsmith_client: MagicMock, ) -> None: - # Given - attributes also has a "value" key - mock_flagsmith_client.track_event = MagicMock() - provider = FlagsmithProvider(mock_flagsmith_client) + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) # When provider.track( "checkout", tracking_event_details=TrackingEventDetails( value=99.77, - attributes={"value": "should_be_overwritten", "other": "kept"}, + attributes={"value": "a-metadata-key", "other": "kept"}, ), ) - # Then - explicit .value wins over attributes["value"] - mock_flagsmith_client.track_event.assert_called_once_with( + # Then + tracking_flagsmith_client.track_event.assert_called_once_with( "checkout", - identity_identifier=None, + identifier=None, + value=99.77, traits=None, - metadata={"value": 99.77, "other": "kept"}, + metadata={"value": "a-metadata-key", "other": "kept"}, ) -def test_track_with_details_value_only(mock_flagsmith_client: MagicMock) -> None: +def test_track_non_numeric_value_is_dropped_with_warning( + tracking_flagsmith_client: MagicMock, +) -> None: # Given - mock_flagsmith_client.track_event = MagicMock() - provider = FlagsmithProvider(mock_flagsmith_client) + provider = FlagsmithProvider(tracking_flagsmith_client) # When - provider.track("checkout", tracking_event_details=TrackingEventDetails(value=99.77)) + provider.track( + "checkout", + tracking_event_details=TrackingEventDetails(value="99.77"), # type: ignore[arg-type] + ) # Then - mock_flagsmith_client.track_event.assert_called_once_with( + tracking_flagsmith_client.track_event.assert_called_once_with( "checkout", - identity_identifier=None, + identifier=None, + value=None, traits=None, - metadata={"value": 99.77}, + metadata=None, ) -def test_track_extracts_traits_from_context(mock_flagsmith_client: MagicMock) -> None: - # Given - nested traits take precedence over flat attributes (same rule as _get_flags) - mock_flagsmith_client.track_event = MagicMock() - provider = FlagsmithProvider(mock_flagsmith_client) +def test_track_extracts_traits_from_context( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) # When provider.track( @@ -585,9 +614,444 @@ def test_track_extracts_traits_from_context(mock_flagsmith_client: MagicMock) -> ) # Then - mock_flagsmith_client.track_event.assert_called_once_with( + tracking_flagsmith_client.track_event.assert_called_once_with( "page_view", - identity_identifier="user-123", + identifier="user-123", traits={"shared_key": "nested_value", "other": "kept"}, + value=None, + metadata=None, + ) + + +def test_track_drops_reserved_dollar_names( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track("$flag_exposure") + provider.track("$anything") + + # Then + tracking_flagsmith_client.track_event.assert_not_called() + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +# --------------------------------------------------------------------------- +# Reasons / variant / flag_metadata +# --------------------------------------------------------------------------- + + +def test_resolve_environment_flag_has_static_reason_and_metadata( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_feature" + mock_flagsmith_client.get_environment_flags.return_value = Flags( + {key: Flag(feature_id=42, feature_name=key, enabled=True, value="foo")} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + result = provider.resolve_string_details(key, default_value="default") + + # Then + assert result.reason == Reason.STATIC + assert result.variant is None + assert result.flag_metadata == {"enabled": True, "featureId": 42} + + +def test_resolve_identity_flag_with_variant_has_experiment_metadata( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_experiment" + mock_flagsmith_client.get_identity_flags.return_value = Flags( + { + key: Flag( + feature_id=7, + feature_name=key, + enabled=True, + value="treatment-value", + variant="treatment", + ) + } + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + result = provider.resolve_string_details( + key, + default_value="control", + evaluation_context=EvaluationContext(targeting_key="user-1"), + ) + + # Then + assert result.reason == Reason.SPLIT + assert result.variant == "treatment" + assert result.flag_metadata == { + "enabled": True, + "featureId": 7, + "experiment.arm": "treatment", + "experiment.active": True, + "experiment.unit": "user", + } + + +def test_resolve_boolean_details_disabled_flag_has_disabled_reason( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_feature" + mock_flagsmith_client.get_environment_flags.return_value = Flags( + {key: Flag(feature_id=1, feature_name=key, enabled=False, value=None)} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + result = provider.resolve_boolean_details(key, default_value=True) + + # Then + assert result.value is False + assert result.reason == Reason.DISABLED + + +def test_resolve_flagsmith_default_flag_metadata_has_no_feature_id( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_feature" + mock_flagsmith_client.get_environment_flags.return_value = Flags( + {key: DefaultFlag(enabled=True, value="foo")} + ) + provider = FlagsmithProvider(mock_flagsmith_client, use_flagsmith_defaults=True) + + # When + result = provider.resolve_string_details(key, default_value="default") + + # Then + assert result.reason == Reason.DEFAULT + assert result.variant is None + assert result.flag_metadata == {"enabled": True} + + +def test_resolve_in_offline_mode_has_stale_reason( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_feature" + mock_flagsmith_client.offline_mode = True + mock_flagsmith_client.get_identity_flags.return_value = Flags( + {key: Flag(feature_id=1, feature_name=key, enabled=True, value="foo")} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + result = provider.resolve_string_details( + key, + default_value="default", + evaluation_context=EvaluationContext(targeting_key="user-1"), + ) + + # Then + assert result.reason == Reason.STALE + + +def test_resolve_object_details_parsed_json_carries_reason_and_metadata( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "my_feature" + mock_flagsmith_client.get_environment_flags.return_value = Flags( + {key: Flag(feature_id=3, feature_name=key, enabled=True, value='{"a": 1}')} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + result = provider.resolve_object_details(key, default_value={}) + + # Then + assert result.value == {"a": 1} + assert result.reason == Reason.STATIC + assert result.flag_metadata == {"enabled": True, "featureId": 3} + + +# --------------------------------------------------------------------------- +# Transient identities +# --------------------------------------------------------------------------- + + +def test_transient_attribute_maps_to_transient_identity( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "key" + mock_flagsmith_client.get_identity_flags.return_value = Flags( + {key: Flag(feature_id=1, feature_name=key, enabled=True, value="foo")} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.resolve_string_details( + flag_key=key, + default_value="default", + evaluation_context=EvaluationContext( + targeting_key="user-1", + attributes={"transient": True, "plan": "pro"}, + ), + ) + + # Then + mock_flagsmith_client.get_identity_flags.assert_called_once_with( + identifier="user-1", traits={"plan": "pro"}, transient=True + ) + + +def test_nested_trait_named_transient_is_kept( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + key = "key" + mock_flagsmith_client.get_identity_flags.return_value = Flags( + {key: Flag(feature_id=1, feature_name=key, enabled=True, value="foo")} + ) + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.resolve_string_details( + flag_key=key, + default_value="default", + evaluation_context=EvaluationContext( + targeting_key="user-1", + attributes={"traits": {"transient": "a-real-trait"}}, + ), + ) + + # Then + mock_flagsmith_client.get_identity_flags.assert_called_once_with( + identifier="user-1", traits={"transient": "a-real-trait"}, transient=False + ) + + +# --------------------------------------------------------------------------- +# Tracking: exposures +# --------------------------------------------------------------------------- + + +def test_exposure_with_explicit_variant_sends_as_rendered( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext( + targeting_key="user-1", attributes={"plan": "pro"} + ), + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "my_exp", "variant": "treatment", "page": "home"} + ), + ) + + # Then + tracking_flagsmith_client.track_exposure_event.assert_called_once_with( + feature_name="my_exp", + identifier="user-1", + value="treatment", + traits={"plan": "pro"}, + metadata={"page": "home"}, + ) + tracking_flagsmith_client.get_identity_flags.assert_not_called() + + +def test_exposure_without_flag_key_is_dropped( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails(attributes={"variant": "t"}), + ) + + # Then + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_exposure_with_non_string_variant_is_dropped( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "my_exp", "variant": 123} + ), + ) + + # Then + tracking_flagsmith_client.track_exposure_event.assert_not_called() + tracking_flagsmith_client.get_identity_flags.assert_not_called() + + +def test_exposure_without_targeting_key_is_skipped( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + tracking_event_details=TrackingEventDetails( + attributes={"flag_key": "my_exp", "variant": "t"} + ), + ) + + # Then + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_variantless_exposure_resolves_flag_and_sends_variant( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + tracking_flagsmith_client.get_identity_flags.return_value = Flags( + { + "my_exp": Flag( + feature_id=1, + feature_name="my_exp", + enabled=True, + value="v", + variant="treatment", + ) + } + ) + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext( + targeting_key="user-1", attributes={"transient": True} + ), + tracking_event_details=TrackingEventDetails(attributes={"flag_key": "my_exp"}), + ) + + # Then + tracking_flagsmith_client.get_identity_flags.assert_called_once_with( + identifier="user-1", traits={}, transient=True + ) + tracking_flagsmith_client.track_exposure_event.assert_called_once_with( + feature_name="my_exp", + identifier="user-1", + value="treatment", + traits=None, metadata=None, ) + + +@pytest.mark.parametrize( + "flag", + [ + pytest.param(DefaultFlag(enabled=True, value="v"), id="default-flag"), + pytest.param( + Flag(feature_id=1, feature_name="my_exp", enabled=False, value="v"), + id="disabled", + ), + pytest.param( + Flag( + feature_id=1, + feature_name="my_exp", + enabled=True, + value="v", + variant=None, + ), + id="no-variant", + ), + ], +) +def test_variantless_exposure_guard_chain_skips( + tracking_flagsmith_client: MagicMock, flag +) -> None: + # Given + tracking_flagsmith_client.get_identity_flags.return_value = Flags({"my_exp": flag}) + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails(attributes={"flag_key": "my_exp"}), + ) + + # Then + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_variantless_exposure_missing_flag_is_skipped( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + tracking_flagsmith_client.get_identity_flags.return_value = Flags({}) + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When / Then + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails(attributes={"flag_key": "nope"}), + ) + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_variantless_exposure_client_error_is_swallowed( + tracking_flagsmith_client: MagicMock, +) -> None: + # Given + tracking_flagsmith_client.get_identity_flags.side_effect = FlagsmithClientError("") + provider = FlagsmithProvider(tracking_flagsmith_client) + + # When / Then + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails(attributes={"flag_key": "my_exp"}), + ) + tracking_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_exposure_is_noop_when_events_disabled( + mock_flagsmith_client: MagicMock, +) -> None: + # Given + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.track( + EXPOSURE_TRACKING_EVENT, + evaluation_context=EvaluationContext(targeting_key="user-1"), + tracking_event_details=TrackingEventDetails(attributes={"flag_key": "my_exp"}), + ) + + # Then + mock_flagsmith_client.get_identity_flags.assert_not_called() + mock_flagsmith_client.track_exposure_event.assert_not_called() + + +def test_package_root_reexports() -> None: + import openfeature_flagsmith + + assert openfeature_flagsmith.FlagsmithProvider is FlagsmithProvider + assert openfeature_flagsmith.EXPOSURE_TRACKING_EVENT == "feature_flag.exposure" + assert openfeature_flagsmith.FlagsmithExposureHook is not None