From 2d63e6ed845020d6ff74cb41d77cf945f9390c82 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 14 Apr 2026 15:26:15 +0200 Subject: [PATCH 1/8] feat: implement-tracking-api-from-python-sdk --- .github/workflows/pytest.yml | 2 +- README.md | 49 +++++++++++ openfeature_flagsmith/provider.py | 46 +++++++++- pyproject.toml | 6 +- tests/test_provider.py | 141 ++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 8988956..96b4ed3 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,7 +11,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12'] steps: - name: Cloning repo diff --git a/README.md b/README.md index 30e6459..ff3cb7f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,55 @@ 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 + +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. + +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. + +```python +from flagsmith import Flagsmith, PipelineAnalyticsConfig +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.track import TrackingEventDetails +from openfeature_flagsmith.provider 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=1000, # optional, default 1000 + flush_interval_seconds=10, # optional, default 10s + ), +) + +api.set_provider(FlagsmithProvider(client=client)) +of_client = api.get_client() + +# Flag evaluations are tracked automatically — no extra code needed +variant = of_client.get_string_value( + "checkout-variant", + "control", + evaluation_context=EvaluationContext(targeting_key="user-123"), +) + +# Track a custom event explicitly +of_client.track( + "purchase", + evaluation_context=EvaluationContext( + targeting_key="user-123", + attributes={"plan": "premium"}, + ), + tracking_event_details=TrackingEventDetails( + value=99.77, + attributes={"currency": "USD"}, + ), +) +``` + +If `pipeline_analytics_config` is not set on the Flagsmith client, calls to `track()` are silently ignored. + ### Evaluation Context The evaluation context supports traits in two ways: diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index 29f0a10..fbb2705 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -12,7 +12,8 @@ TypeMismatchError, ) from openfeature.flag_evaluation import FlagResolutionDetails, FlagType -from openfeature.provider import Metadata, AbstractProvider +from openfeature.provider import AbstractProvider, Metadata +from openfeature.track import TrackingEventDetails from openfeature_flagsmith.exceptions import FlagsmithProviderError @@ -37,6 +38,38 @@ def __init__( self.use_flagsmith_defaults = use_flagsmith_defaults self.use_boolean_config_value = use_boolean_config_value + def track( + self, + tracking_event_name: str, + evaluation_context: typing.Optional[EvaluationContext] = None, + tracking_event_details: typing.Optional[TrackingEventDetails] = None, + ) -> None: + # Guard against older flagsmith versions or duck-typed clients + # that don't have track_event. + if not hasattr(self._client, "track_event"): + return + + identity = evaluation_context.targeting_key if evaluation_context else None + traits = self._extract_traits(evaluation_context) + + metadata: typing.Optional[typing.Dict[str, typing.Any]] = None + if tracking_event_details is not None: + metadata = dict(tracking_event_details.attributes) + if tracking_event_details.value is not None: + metadata["value"] = tracking_event_details.value + if not metadata: + metadata = None + + try: + self._client.track_event( + tracking_event_name, + identity_identifier=identity, + traits=traits, + metadata=metadata, + ) + except ValueError: + return + def get_metadata(self) -> Metadata: return Metadata(name="FlagsmithProvider") @@ -132,6 +165,17 @@ def _resolve( % (flag_key, flag_type.value) ) + @staticmethod + def _extract_traits( + evaluation_context: typing.Optional[EvaluationContext], + ) -> typing.Optional[typing.Dict[str, typing.Any]]: + 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"} + merged = {**flat, **nested} + return merged or None + def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()): if targeting_key := evaluation_context.targeting_key: nested_traits = evaluation_context.attributes.pop("traits", {}) diff --git a/pyproject.toml b/pyproject.toml index 6b3ab81..9016d7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,10 @@ authors = [ { name = "Matthew Elwell", email = "matthew.elwell@flagsmith.com>" } ] readme = "README.md" -requires-python = ">=3.9,<4.0" +requires-python = ">=3.10,<4.0" dependencies = [ - "flagsmith (>=3.6.0,<6.0.0)", - "openfeature-sdk (>=0.6.0,<0.9.0)", + "flagsmith (>=5.2.0)", + "openfeature-sdk (>=0.9.0,<0.10.0)", ] [tool.poetry] diff --git a/tests/test_provider.py b/tests/test_provider.py index 75d9be3..1da313a 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -11,6 +11,7 @@ ParseError, FlagNotFoundError, ) +from openfeature.track import TrackingEventDetails from openfeature_flagsmith.exceptions import FlagsmithProviderError from openfeature_flagsmith.provider import FlagsmithProvider @@ -450,3 +451,143 @@ def test_resolve_boolean_details_uses_enabled_when_use_boolean_config_value_is_f assert result.value is True assert result.error_code is None assert result.reason is None + + +# --------------------------------------------------------------------------- +# Tracking +# --------------------------------------------------------------------------- + + +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) + + # When / Then - no error raised + provider.track("purchase") + + +def test_track_is_noop_when_pipeline_analytics_not_configured( + mock_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) + + # When / Then - no error raised, ValueError caught silently + provider.track("purchase") + + +def test_track_delegates_to_client(mock_flagsmith_client: MagicMock) -> None: + # Given + mock_flagsmith_client.track_event = MagicMock() + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.track( + "purchase", + evaluation_context=EvaluationContext( + targeting_key="user-123", + attributes={"plan": "premium"}, + ), + tracking_event_details=TrackingEventDetails( + value=99.77, + attributes={"currency": "USD"}, + ), + ) + + # Then + mock_flagsmith_client.track_event.assert_called_once_with( + "purchase", + identity_identifier="user-123", + traits={"plan": "premium"}, + metadata={"value": 99.77, "currency": "USD"}, + ) + + +def test_track_with_minimal_args(mock_flagsmith_client: MagicMock) -> None: + # Given + mock_flagsmith_client.track_event = MagicMock() + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.track("signup") + + # Then + mock_flagsmith_client.track_event.assert_called_once_with( + "signup", + identity_identifier=None, + traits=None, + metadata=None, + ) + + +def test_track_value_takes_precedence_over_attributes_value( + mock_flagsmith_client: MagicMock, +) -> None: + # Given - attributes also has a "value" key + mock_flagsmith_client.track_event = MagicMock() + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.track( + "checkout", + tracking_event_details=TrackingEventDetails( + value=99.77, + attributes={"value": "should_be_overwritten", "other": "kept"}, + ), + ) + + # Then - explicit .value wins over attributes["value"] + mock_flagsmith_client.track_event.assert_called_once_with( + "checkout", + identity_identifier=None, + traits=None, + metadata={"value": 99.77, "other": "kept"}, + ) + + +def test_track_with_details_value_only(mock_flagsmith_client: MagicMock) -> None: + # Given + mock_flagsmith_client.track_event = MagicMock() + provider = FlagsmithProvider(mock_flagsmith_client) + + # When + provider.track("checkout", tracking_event_details=TrackingEventDetails(value=99.77)) + + # Then + mock_flagsmith_client.track_event.assert_called_once_with( + "checkout", + identity_identifier=None, + traits=None, + metadata={"value": 99.77}, + ) + + +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) + + # When + provider.track( + "page_view", + evaluation_context=EvaluationContext( + targeting_key="user-123", + attributes={ + "shared_key": "flat_value", + "other": "kept", + "traits": {"shared_key": "nested_value"}, + }, + ), + ) + + # Then + mock_flagsmith_client.track_event.assert_called_once_with( + "page_view", + identity_identifier="user-123", + traits={"shared_key": "nested_value", "other": "kept"}, + metadata=None, + ) From 0e939cee8fbf5183c14c25c155ec0afdd3829486 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 08:43:59 +0200 Subject: [PATCH 2/8] feat: regenerated lock with of version 9.0.0 --- poetry.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5310c33..f6d3e9e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "certifi" @@ -156,7 +156,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, @@ -184,14 +184,14 @@ typing = ["typing-extensions (>=4.8) ; python_version < \"3.11\""] [[package]] name = "flagsmith" -version = "5.1.1" +version = "5.2.0" description = "Flagsmith Python SDK" optional = false python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "flagsmith-5.1.1-py3-none-any.whl", hash = "sha256:220943ff42bb631d4fad0f7d9ce89f56738d480657ce4aeb4b2cc779dcaea66a"}, - {file = "flagsmith-5.1.1.tar.gz", hash = "sha256:42fc5ad3eaa578777e4f6b955a44349e974f6d79ee004b220847a9b0ca778d91"}, + {file = "flagsmith-5.2.0-py3-none-any.whl", hash = "sha256:07114d9ccaa1206d13a995bcd99a96ea4c4b7bda8c731b1d023ca233189879cc"}, + {file = "flagsmith-5.2.0.tar.gz", hash = "sha256:734d6ea733586fed2d96714203f8fb4f997e0039f3a4966f02c51574a7786d68"}, ] [package.dependencies] @@ -290,7 +290,7 @@ description = "Simple module to parse ISO 8601 dates" optional = false python-versions = ">=3.7,<4.0" groups = ["main"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"}, {file = "iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df"}, @@ -329,14 +329,14 @@ setuptools = "*" [[package]] name = "openfeature-sdk" -version = "0.8.1" +version = "0.9.0" description = "Standardizing Feature Flagging for Everyone" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "openfeature_sdk-0.8.1-py3-none-any.whl", hash = "sha256:8320724d4567bcc4af638475d56a888866006cdea44a973fc4d7307412687489"}, - {file = "openfeature_sdk-0.8.1.tar.gz", hash = "sha256:475681c39b19ff33bd335e6f6b9bb79180a6f7def95a9aa2c3d6f2693d5f6ac1"}, + {file = "openfeature_sdk-0.9.0-py3-none-any.whl", hash = "sha256:511fd66fe66807e95ea5a18074b559bc18657fe8294be0de4668201e297e98ad"}, + {file = "openfeature_sdk-0.9.0.tar.gz", hash = "sha256:d65ca82c3f1abc5005b0b54bb4c729db04d7a70595f05dd373b47477b6b176bf"}, ] [[package]] @@ -742,7 +742,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -801,5 +801,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" -python-versions = ">=3.9,<4.0" -content-hash = "e6bd2d18cbfb74b3b5f7f8f770f673956bc52a9afadf15990b6ef560ad8d1650" +python-versions = ">=3.10,<4.0" +content-hash = "23b19fe3ad6c11266240949309219ca3058bd8a437a9ba4450baf80eaa4b2143" From ef17444fad96889809d9372586a0378f992454f7 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 08:49:19 +0200 Subject: [PATCH 3/8] feat: added newer python version to test matrix --- .github/workflows/pytest.yml | 2 +- README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 96b4ed3..687ec41 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -11,7 +11,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Cloning repo diff --git a/README.md b/README.md index ff3cb7f..a1c6e08 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ client = Flagsmith( environment_key="your-environment-key", pipeline_analytics_config=PipelineAnalyticsConfig( analytics_server_url="https://analytics-collector.flagsmith.com/", - max_buffer=1000, # optional, default 1000 + max_buffer_items=1000, # optional, default 1000 flush_interval_seconds=10, # optional, default 10s ), ) diff --git a/pyproject.toml b/pyproject.toml index 9016d7b..bfd089b 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)", + "flagsmith (>=5.2.0,<7.0.0)", "openfeature-sdk (>=0.9.0,<0.10.0)", ] From 3de365f083415da8193a9015a23b90fde4f227b9 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 08:51:07 +0200 Subject: [PATCH 4/8] feat: added docstrings --- openfeature_flagsmith/provider.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index fbb2705..2ba8788 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -44,6 +44,13 @@ def track( evaluation_context: typing.Optional[EvaluationContext] = None, tracking_event_details: typing.Optional[TrackingEventDetails] = None, ) -> None: + """ + Records a custom event via the Flagsmith client's pipeline analytics. + + No-ops if the client lacks pipeline analytics support or configuration. + An explicit ``tracking_event_details.value`` overrides any same-named + key in ``attributes``. + """ # Guard against older flagsmith versions or duck-typed clients # that don't have track_event. if not hasattr(self._client, "track_event"): @@ -68,6 +75,8 @@ def track( metadata=metadata, ) except ValueError: + # Flagsmith raises ValueError when pipeline analytics is not + # configured; OpenFeature spec requires track() to no-op. return def get_metadata(self) -> Metadata: From af045b6bc95470db04f77148e8825e0a0fc5194e Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 09:06:31 +0200 Subject: [PATCH 5/8] feat: regenerated lock --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index f6d3e9e..7c276dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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 = "23b19fe3ad6c11266240949309219ca3058bd8a437a9ba4450baf80eaa4b2143" +content-hash = "59e0a22ab4299d665eebfd14d8a178a90c158c61a3b019194661a9f19d071942" From f12a2b92eea54bd6bc7d369c7ca48d553feca730 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 11:01:01 +0200 Subject: [PATCH 6/8] feat: renamed identity to identifier --- openfeature_flagsmith/provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index 2ba8788..5655c2f 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -56,7 +56,7 @@ def track( if not hasattr(self._client, "track_event"): return - identity = evaluation_context.targeting_key if evaluation_context else None + identifier = evaluation_context.targeting_key if evaluation_context else None traits = self._extract_traits(evaluation_context) metadata: typing.Optional[typing.Dict[str, typing.Any]] = None @@ -70,7 +70,7 @@ def track( try: self._client.track_event( tracking_event_name, - identity_identifier=identity, + identity_identifier=identifier, traits=traits, metadata=metadata, ) From 763d1c4142edd6db792e610bd60ca7e45856526c Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 11:02:14 +0200 Subject: [PATCH 7/8] feat: document metadata with a typed dict --- openfeature_flagsmith/provider.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index 5655c2f..5d875cf 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -25,6 +25,17 @@ } +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, @@ -59,9 +70,11 @@ def track( identifier = evaluation_context.targeting_key if evaluation_context else None traits = self._extract_traits(evaluation_context) - metadata: typing.Optional[typing.Dict[str, typing.Any]] = None + metadata: typing.Optional[TrackingMetadata] = None if tracking_event_details is not None: - metadata = dict(tracking_event_details.attributes) + metadata = typing.cast( + TrackingMetadata, dict(tracking_event_details.attributes) + ) if tracking_event_details.value is not None: metadata["value"] = tracking_event_details.value if not metadata: From b01c861c3966781f962f1d5263fdc657bb2553af Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 22 Apr 2026 11:04:27 +0200 Subject: [PATCH 8/8] feat: reuse _extract_traits in _get_flags --- openfeature_flagsmith/provider.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openfeature_flagsmith/provider.py b/openfeature_flagsmith/provider.py index 5d875cf..9e075ae 100644 --- a/openfeature_flagsmith/provider.py +++ b/openfeature_flagsmith/provider.py @@ -200,10 +200,8 @@ def _extract_traits( def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()): if targeting_key := evaluation_context.targeting_key: - nested_traits = evaluation_context.attributes.pop("traits", {}) - flattened_traits = {**evaluation_context.attributes, **nested_traits} return self._client.get_identity_flags( identifier=targeting_key, - traits=flattened_traits, + traits=self._extract_traits(evaluation_context) or {}, ) return self._client.get_environment_flags()