Skip to content

Commit 872bd8c

Browse files
authored
feat: Implement Tracking API (#35)
* feat: implement-tracking-api-from-python-sdk * feat: regenerated lock with of version 9.0.0 * feat: added newer python version to test matrix * feat: added docstrings * feat: regenerated lock * feat: renamed identity to identifier * feat: document metadata with a typed dict * feat: reuse _extract_traits in _get_flags
1 parent f5cec15 commit 872bd8c

6 files changed

Lines changed: 275 additions & 21 deletions

File tree

.github/workflows/pytest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
max-parallel: 4
1313
matrix:
14-
python-version: ['3.9', '3.10', '3.11', '3.12']
14+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
1515

1616
steps:
1717
- name: Cloning repo

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,55 @@ provider = FlagsmithProvider(
6060
The provider can then be used with the OpenFeature client as per
6161
[the documentation](https://openfeature.dev/docs/reference/concepts/evaluation-api#setting-a-provider).
6262

63+
### Tracking
64+
65+
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.
66+
67+
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.
68+
69+
```python
70+
from flagsmith import Flagsmith, PipelineAnalyticsConfig
71+
from openfeature import api
72+
from openfeature.evaluation_context import EvaluationContext
73+
from openfeature.track import TrackingEventDetails
74+
from openfeature_flagsmith.provider import FlagsmithProvider
75+
76+
# Enable pipeline analytics on the Flagsmith client
77+
client = Flagsmith(
78+
environment_key="your-environment-key",
79+
pipeline_analytics_config=PipelineAnalyticsConfig(
80+
analytics_server_url="https://analytics-collector.flagsmith.com/",
81+
max_buffer_items=1000, # optional, default 1000
82+
flush_interval_seconds=10, # optional, default 10s
83+
),
84+
)
85+
86+
api.set_provider(FlagsmithProvider(client=client))
87+
of_client = api.get_client()
88+
89+
# Flag evaluations are tracked automatically — no extra code needed
90+
variant = of_client.get_string_value(
91+
"checkout-variant",
92+
"control",
93+
evaluation_context=EvaluationContext(targeting_key="user-123"),
94+
)
95+
96+
# Track a custom event explicitly
97+
of_client.track(
98+
"purchase",
99+
evaluation_context=EvaluationContext(
100+
targeting_key="user-123",
101+
attributes={"plan": "premium"},
102+
),
103+
tracking_event_details=TrackingEventDetails(
104+
value=99.77,
105+
attributes={"currency": "USD"},
106+
),
107+
)
108+
```
109+
110+
If `pipeline_analytics_config` is not set on the Flagsmith client, calls to `track()` are silently ignored.
111+
63112
### Evaluation Context
64113

65114
The evaluation context supports traits in two ways:

openfeature_flagsmith/provider.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
TypeMismatchError,
1313
)
1414
from openfeature.flag_evaluation import FlagResolutionDetails, FlagType
15-
from openfeature.provider import Metadata, AbstractProvider
15+
from openfeature.provider import AbstractProvider, Metadata
16+
from openfeature.track import TrackingEventDetails
1617

1718
from openfeature_flagsmith.exceptions import FlagsmithProviderError
1819

@@ -24,6 +25,17 @@
2425
}
2526

2627

28+
class TrackingMetadata(typing.TypedDict, total=False):
29+
"""
30+
Shape of the metadata dict forwarded to ``Flagsmith.track_event``.
31+
32+
``value`` holds the numeric value from ``TrackingEventDetails.value`` when
33+
set. All other keys pass through from ``TrackingEventDetails.attributes``.
34+
"""
35+
36+
value: float
37+
38+
2739
class FlagsmithProvider(AbstractProvider):
2840
def __init__(
2941
self,
@@ -37,6 +49,49 @@ def __init__(
3749
self.use_flagsmith_defaults = use_flagsmith_defaults
3850
self.use_boolean_config_value = use_boolean_config_value
3951

52+
def track(
53+
self,
54+
tracking_event_name: str,
55+
evaluation_context: typing.Optional[EvaluationContext] = None,
56+
tracking_event_details: typing.Optional[TrackingEventDetails] = None,
57+
) -> None:
58+
"""
59+
Records a custom event via the Flagsmith client's pipeline analytics.
60+
61+
No-ops if the client lacks pipeline analytics support or configuration.
62+
An explicit ``tracking_event_details.value`` overrides any same-named
63+
key in ``attributes``.
64+
"""
65+
# Guard against older flagsmith versions or duck-typed clients
66+
# that don't have track_event.
67+
if not hasattr(self._client, "track_event"):
68+
return
69+
70+
identifier = evaluation_context.targeting_key if evaluation_context else None
71+
traits = self._extract_traits(evaluation_context)
72+
73+
metadata: typing.Optional[TrackingMetadata] = None
74+
if tracking_event_details is not None:
75+
metadata = typing.cast(
76+
TrackingMetadata, dict(tracking_event_details.attributes)
77+
)
78+
if tracking_event_details.value is not None:
79+
metadata["value"] = tracking_event_details.value
80+
if not metadata:
81+
metadata = None
82+
83+
try:
84+
self._client.track_event(
85+
tracking_event_name,
86+
identity_identifier=identifier,
87+
traits=traits,
88+
metadata=metadata,
89+
)
90+
except ValueError:
91+
# Flagsmith raises ValueError when pipeline analytics is not
92+
# configured; OpenFeature spec requires track() to no-op.
93+
return
94+
4095
def get_metadata(self) -> Metadata:
4196
return Metadata(name="FlagsmithProvider")
4297

@@ -132,12 +187,21 @@ def _resolve(
132187
% (flag_key, flag_type.value)
133188
)
134189

190+
@staticmethod
191+
def _extract_traits(
192+
evaluation_context: typing.Optional[EvaluationContext],
193+
) -> typing.Optional[typing.Dict[str, typing.Any]]:
194+
if not evaluation_context or not evaluation_context.attributes:
195+
return None
196+
nested = evaluation_context.attributes.get("traits", {})
197+
flat = {k: v for k, v in evaluation_context.attributes.items() if k != "traits"}
198+
merged = {**flat, **nested}
199+
return merged or None
200+
135201
def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()):
136202
if targeting_key := evaluation_context.targeting_key:
137-
nested_traits = evaluation_context.attributes.pop("traits", {})
138-
flattened_traits = {**evaluation_context.attributes, **nested_traits}
139203
return self._client.get_identity_flags(
140204
identifier=targeting_key,
141-
traits=flattened_traits,
205+
traits=self._extract_traits(evaluation_context) or {},
142206
)
143207
return self._client.get_environment_flags()

poetry.lock

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ authors = [
66
{ name = "Matthew Elwell", email = "matthew.elwell@flagsmith.com>" }
77
]
88
readme = "README.md"
9-
requires-python = ">=3.9,<4.0"
9+
requires-python = ">=3.10,<4.0"
1010
dependencies = [
11-
"flagsmith (>=3.6.0,<6.0.0)",
12-
"openfeature-sdk (>=0.6.0,<0.9.0)",
11+
"flagsmith (>=5.2.0,<7.0.0)",
12+
"openfeature-sdk (>=0.9.0,<0.10.0)",
1313
]
1414

1515
[tool.poetry]

0 commit comments

Comments
 (0)