Skip to content

Commit 2d63e6e

Browse files
committed
feat: implement-tracking-api-from-python-sdk
1 parent f5cec15 commit 2d63e6e

5 files changed

Lines changed: 239 additions & 5 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']
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=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: 45 additions & 1 deletion
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

@@ -37,6 +38,38 @@ def __init__(
3738
self.use_flagsmith_defaults = use_flagsmith_defaults
3839
self.use_boolean_config_value = use_boolean_config_value
3940

41+
def track(
42+
self,
43+
tracking_event_name: str,
44+
evaluation_context: typing.Optional[EvaluationContext] = None,
45+
tracking_event_details: typing.Optional[TrackingEventDetails] = None,
46+
) -> None:
47+
# Guard against older flagsmith versions or duck-typed clients
48+
# that don't have track_event.
49+
if not hasattr(self._client, "track_event"):
50+
return
51+
52+
identity = evaluation_context.targeting_key if evaluation_context else None
53+
traits = self._extract_traits(evaluation_context)
54+
55+
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None
56+
if tracking_event_details is not None:
57+
metadata = dict(tracking_event_details.attributes)
58+
if tracking_event_details.value is not None:
59+
metadata["value"] = tracking_event_details.value
60+
if not metadata:
61+
metadata = None
62+
63+
try:
64+
self._client.track_event(
65+
tracking_event_name,
66+
identity_identifier=identity,
67+
traits=traits,
68+
metadata=metadata,
69+
)
70+
except ValueError:
71+
return
72+
4073
def get_metadata(self) -> Metadata:
4174
return Metadata(name="FlagsmithProvider")
4275

@@ -132,6 +165,17 @@ def _resolve(
132165
% (flag_key, flag_type.value)
133166
)
134167

168+
@staticmethod
169+
def _extract_traits(
170+
evaluation_context: typing.Optional[EvaluationContext],
171+
) -> typing.Optional[typing.Dict[str, typing.Any]]:
172+
if not evaluation_context or not evaluation_context.attributes:
173+
return None
174+
nested = evaluation_context.attributes.get("traits", {})
175+
flat = {k: v for k, v in evaluation_context.attributes.items() if k != "traits"}
176+
merged = {**flat, **nested}
177+
return merged or None
178+
135179
def _get_flags(self, evaluation_context: EvaluationContext = EvaluationContext()):
136180
if targeting_key := evaluation_context.targeting_key:
137181
nested_traits = evaluation_context.attributes.pop("traits", {})

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)",
12+
"openfeature-sdk (>=0.9.0,<0.10.0)",
1313
]
1414

1515
[tool.poetry]

tests/test_provider.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ParseError,
1212
FlagNotFoundError,
1313
)
14+
from openfeature.track import TrackingEventDetails
1415

1516
from openfeature_flagsmith.exceptions import FlagsmithProviderError
1617
from openfeature_flagsmith.provider import FlagsmithProvider
@@ -450,3 +451,143 @@ def test_resolve_boolean_details_uses_enabled_when_use_boolean_config_value_is_f
450451
assert result.value is True
451452
assert result.error_code is None
452453
assert result.reason is None
454+
455+
456+
# ---------------------------------------------------------------------------
457+
# Tracking
458+
# ---------------------------------------------------------------------------
459+
460+
461+
def test_track_is_noop_without_track_event_on_client() -> None:
462+
# Given - client without track_event (e.g. older flagsmith version)
463+
client = MagicMock(spec=[])
464+
provider = FlagsmithProvider(client)
465+
466+
# When / Then - no error raised
467+
provider.track("purchase")
468+
469+
470+
def test_track_is_noop_when_pipeline_analytics_not_configured(
471+
mock_flagsmith_client: MagicMock,
472+
) -> None:
473+
# Given - client has track_event but raises ValueError (no analytics config)
474+
mock_flagsmith_client.track_event = MagicMock(
475+
side_effect=ValueError("Pipeline analytics is not configured")
476+
)
477+
provider = FlagsmithProvider(mock_flagsmith_client)
478+
479+
# When / Then - no error raised, ValueError caught silently
480+
provider.track("purchase")
481+
482+
483+
def test_track_delegates_to_client(mock_flagsmith_client: MagicMock) -> None:
484+
# Given
485+
mock_flagsmith_client.track_event = MagicMock()
486+
provider = FlagsmithProvider(mock_flagsmith_client)
487+
488+
# When
489+
provider.track(
490+
"purchase",
491+
evaluation_context=EvaluationContext(
492+
targeting_key="user-123",
493+
attributes={"plan": "premium"},
494+
),
495+
tracking_event_details=TrackingEventDetails(
496+
value=99.77,
497+
attributes={"currency": "USD"},
498+
),
499+
)
500+
501+
# Then
502+
mock_flagsmith_client.track_event.assert_called_once_with(
503+
"purchase",
504+
identity_identifier="user-123",
505+
traits={"plan": "premium"},
506+
metadata={"value": 99.77, "currency": "USD"},
507+
)
508+
509+
510+
def test_track_with_minimal_args(mock_flagsmith_client: MagicMock) -> None:
511+
# Given
512+
mock_flagsmith_client.track_event = MagicMock()
513+
provider = FlagsmithProvider(mock_flagsmith_client)
514+
515+
# When
516+
provider.track("signup")
517+
518+
# Then
519+
mock_flagsmith_client.track_event.assert_called_once_with(
520+
"signup",
521+
identity_identifier=None,
522+
traits=None,
523+
metadata=None,
524+
)
525+
526+
527+
def test_track_value_takes_precedence_over_attributes_value(
528+
mock_flagsmith_client: MagicMock,
529+
) -> None:
530+
# Given - attributes also has a "value" key
531+
mock_flagsmith_client.track_event = MagicMock()
532+
provider = FlagsmithProvider(mock_flagsmith_client)
533+
534+
# When
535+
provider.track(
536+
"checkout",
537+
tracking_event_details=TrackingEventDetails(
538+
value=99.77,
539+
attributes={"value": "should_be_overwritten", "other": "kept"},
540+
),
541+
)
542+
543+
# Then - explicit .value wins over attributes["value"]
544+
mock_flagsmith_client.track_event.assert_called_once_with(
545+
"checkout",
546+
identity_identifier=None,
547+
traits=None,
548+
metadata={"value": 99.77, "other": "kept"},
549+
)
550+
551+
552+
def test_track_with_details_value_only(mock_flagsmith_client: MagicMock) -> None:
553+
# Given
554+
mock_flagsmith_client.track_event = MagicMock()
555+
provider = FlagsmithProvider(mock_flagsmith_client)
556+
557+
# When
558+
provider.track("checkout", tracking_event_details=TrackingEventDetails(value=99.77))
559+
560+
# Then
561+
mock_flagsmith_client.track_event.assert_called_once_with(
562+
"checkout",
563+
identity_identifier=None,
564+
traits=None,
565+
metadata={"value": 99.77},
566+
)
567+
568+
569+
def test_track_extracts_traits_from_context(mock_flagsmith_client: MagicMock) -> None:
570+
# Given - nested traits take precedence over flat attributes (same rule as _get_flags)
571+
mock_flagsmith_client.track_event = MagicMock()
572+
provider = FlagsmithProvider(mock_flagsmith_client)
573+
574+
# When
575+
provider.track(
576+
"page_view",
577+
evaluation_context=EvaluationContext(
578+
targeting_key="user-123",
579+
attributes={
580+
"shared_key": "flat_value",
581+
"other": "kept",
582+
"traits": {"shared_key": "nested_value"},
583+
},
584+
),
585+
)
586+
587+
# Then
588+
mock_flagsmith_client.track_event.assert_called_once_with(
589+
"page_view",
590+
identity_identifier="user-123",
591+
traits={"shared_key": "nested_value", "other": "kept"},
592+
metadata=None,
593+
)

0 commit comments

Comments
 (0)