Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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', '3.13', '3.14']

steps:
- name: Cloning repo
Expand Down
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_items=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:
Expand Down
72 changes: 68 additions & 4 deletions openfeature_flagsmith/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,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,
Expand All @@ -37,6 +49,49 @@ 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:
"""
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"):
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_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=identifier,
traits=traits,
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:
return Metadata(name="FlagsmithProvider")

Expand Down Expand Up @@ -132,12 +187,21 @@ 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
Comment thread
khvn26 marked this conversation as resolved.

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()
26 changes: 13 additions & 13 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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,<7.0.0)",
"openfeature-sdk (>=0.9.0,<0.10.0)",
]

[tool.poetry]
Expand Down
Loading
Loading