Skip to content
Open
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
25 changes: 25 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: We should really have a matrix analogous to what we have in the Python SDK repo. Can be a follow-up issue/PR.


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
87 changes: 68 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions openfeature_flagsmith/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
75 changes: 75 additions & 0 deletions openfeature_flagsmith/hooks.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading