Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1cbc3d5
add: Implement tracking
danjuv Feb 1, 2026
55e8a2f
chore: update README.md
danjuv Feb 1, 2026
4525b01
add: track method to providers
danjuv Feb 1, 2026
e04c58b
add: add TrackingEventDetails definition
danjuv Feb 1, 2026
2501cbb
add: add tests
danjuv Feb 1, 2026
8a5cf75
fix: add newlines
danjuv Feb 1, 2026
9e2ef06
chore: update test decorators
danjuv Feb 2, 2026
b1170e1
chore: add docstring for track method
danjuv Feb 2, 2026
d35769e
refactor: rename to eval_context_attributes
danjuv Feb 2, 2026
cce9a7a
refactor: better naming for tracking events for inmemory provider
danjuv Feb 2, 2026
8ca3e73
chore: add Tracking to list of features in README
danjuv Feb 2, 2026
48ecabb
chore: fix example syntax in README
danjuv Feb 2, 2026
7a0993e
feat: add track method to base FeatureProvider class
danjuv Feb 2, 2026
63c5a5b
chore: remove unnecessary async from tests
danjuv Feb 2, 2026
6451cec
chore: fix typing
danjuv Feb 2, 2026
1bb99e1
chore: fix typo in readme
danjuv Feb 2, 2026
4adaa5b
fix: use hasattr to check for track method
danjuv Feb 2, 2026
32d972e
chore: fix example in README
danjuv Feb 2, 2026
7780fa4
chore: run pre-commit
danjuv Feb 3, 2026
7a24de9
remove unnecessary track definitions
danjuv Feb 19, 2026
1bf33c7
fix: remove unused import
danjuv Feb 19, 2026
cdadfaf
fix: remove unnecessary async
danjuv Mar 20, 2026
bb65dbd
chore: use correct import path in README
danjuv Mar 20, 2026
ba7fa40
Merge remote-tracking branch 'origin/main' into feat/add-tracking
danjuv Mar 20, 2026
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ print("Value: " + str(flag_value))
| ✅ | [Logging](#logging) | Integrate with popular logging packages. |
| ✅ | [Domains](#domains) | Logically bind clients with providers. |
| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
| ✅ | [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
| ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
| ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) |
| ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
Expand Down Expand Up @@ -185,6 +186,27 @@ client.add_hooks([MyHook()])
options = FlagEvaluationOptions(hooks=[MyHook()])
client.get_boolean_flag("my-flag", False, flag_evaluation_options=options)
```
### Tracking

The [tracking API](https://openfeature.dev/specification/sections/tracking/) allows you to use OpenFeature abstractions and objects to associate user actions with feature flag evaluations.
This is essential for robust experimentation powered by feature flags.
For example, a flag enhancing the appearance of a UI component might drive user engagement to a new feature; to test this hypothesis, telemetry collected by a [hook](#hooks) or [provider](#providers) can be associated with telemetry reported in the client's `track` function.

```python
from openfeature.track import TrackingEventDetails

# initialize a client
client = api.get_client()

# trigger tracking event action
client.track(
'visited-promo-page',
evaluation_context=EvaluationContext(),
tracking_event_details=TrackingEventDetails(99.77).add("currencyCode", "USD"),
)
```

Note that some providers may not support tracking; check the documentation for your provider for more information.

### Logging

Expand Down
28 changes: 28 additions & 0 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
)
from openfeature.provider import FeatureProvider, ProviderStatus
from openfeature.provider._registry import provider_registry
from openfeature.track import TrackingEventDetails
from openfeature.transaction_context import get_transaction_context

__all__ = [
Expand Down Expand Up @@ -955,6 +956,33 @@ def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
_event_support.remove_client_handler(self, event, handler)

def track(
self,
tracking_event_name: str,
evaluation_context: EvaluationContext | None = None,
tracking_event_details: TrackingEventDetails | None = None,
) -> None:
"""
Tracks the occurrence of a particular action or application state.

:param tracking_event_name: the name of the tracking event
:param evaluation_context: the evaluation context
:param tracking_event_details: Optional data relevant to the tracking event
"""

if evaluation_context is None:
evaluation_context = EvaluationContext()

merged_eval_context = (
get_evaluation_context()
.merge(get_transaction_context())
.merge(self.context)
.merge(evaluation_context)
)
self.provider.track(
tracking_event_name, merged_eval_context, tracking_event_details
)


def _typecheck_flag_value(
value: typing.Any, flag_type: FlagType
Expand Down
16 changes: 16 additions & 0 deletions openfeature/provider/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from openfeature.event import ProviderEvent, ProviderEventDetails
from openfeature.flag_evaluation import FlagResolutionDetails
from openfeature.hook import Hook
from openfeature.track import TrackingEventDetails

from .metadata import Metadata

Expand Down Expand Up @@ -116,6 +117,13 @@ async def resolve_object_details_async(
Sequence[FlagValueType] | Mapping[str, FlagValueType]
]: ...

def track(
self,
tracking_event_name: str,
evaluation_context: EvaluationContext | None = None,
tracking_event_details: TrackingEventDetails | None = None,
) -> None: ...


class AbstractProvider(FeatureProvider):
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
Expand All @@ -138,6 +146,14 @@ def initialize(self, evaluation_context: EvaluationContext) -> None:
def shutdown(self) -> None:
pass

def track(
self,
tracking_event_name: str,
evaluation_context: EvaluationContext | None = None,
tracking_event_details: TrackingEventDetails | None = None,
) -> None:
pass

@abstractmethod
def get_metadata(self) -> Metadata:
pass
Expand Down
48 changes: 46 additions & 2 deletions openfeature/provider/in_memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from dataclasses import dataclass, field

from openfeature._backports.strenum import StrEnum
from openfeature.evaluation_context import EvaluationContext
from openfeature.evaluation_context import EvaluationContext, EvaluationContextAttribute
from openfeature.exception import ErrorCode
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
from openfeature.provider import AbstractProvider, Metadata
from openfeature.track import TrackingEventDetails

if typing.TYPE_CHECKING:
from openfeature.flag_evaluation import FlagMetadata, FlagValueType
Expand All @@ -22,6 +23,15 @@ class InMemoryMetadata(Metadata):
name: str = "In-Memory Provider"


@dataclass
class InMemoryTrackingEvent:
value: float | None = None
details: dict[str, typing.Any] = field(default_factory=dict)
eval_context_attributes: Mapping[str, EvaluationContextAttribute] = field(
default_factory=dict
)


T_co = typing.TypeVar("T_co", covariant=True)


Expand Down Expand Up @@ -58,14 +68,24 @@ def resolve(

FlagStorage = dict[str, InMemoryFlag[typing.Any]]

TrackingStorage = dict[str, InMemoryTrackingEvent]

V = typing.TypeVar("V")


class InMemoryProvider(AbstractProvider):
_flags: FlagStorage
_tracking_events: TrackingStorage

def __init__(self, flags: FlagStorage) -> None:
# tracking_events defaults to an empty dict
def __init__(
self, flags: FlagStorage, tracking_events: TrackingStorage | None = None
) -> None:
self._flags = flags.copy()
if tracking_events is not None:
self._tracking_events = tracking_events.copy()
else:
self._tracking_events = {}

def get_metadata(self) -> Metadata:
return InMemoryMetadata()
Expand Down Expand Up @@ -176,3 +196,27 @@ async def _resolve_async(
evaluation_context: EvaluationContext | None,
) -> FlagResolutionDetails[V]:
return self._resolve(flag_key, default_value, evaluation_context)

def track(
self,
tracking_event_name: str,
evaluation_context: EvaluationContext | None = None,
tracking_event_details: TrackingEventDetails | None = None,
) -> None:
value = (
tracking_event_details.value if tracking_event_details is not None else None
)
details = (
tracking_event_details.attributes
if tracking_event_details is not None
else {}
)
Comment on lines +206 to +213
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
value = (
tracking_event_details.value if tracking_event_details is not None else None
)
details = (
tracking_event_details.attributes
if tracking_event_details is not None
else {}
)
value = None
details = {}
if tracking_event_details:
value = tracking_event_details.value
details = tracking_event_details.attributes

then you only check tracking_event_details once

eval_context_attributes = (
evaluation_context.attributes if evaluation_context is not None else {}
)

self._tracking_events[tracking_event_name] = InMemoryTrackingEvent(
value=value,
details=details,
eval_context_attributes=eval_context_attributes,
)
25 changes: 25 additions & 0 deletions openfeature/track/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

import typing
from collections.abc import Mapping, Sequence

TrackingValue: typing.TypeAlias = (
bool | int | float | str | Sequence["TrackingValue"] | Mapping[str, "TrackingValue"]
)


class TrackingEventDetails:
value: float | None
attributes: dict[str, TrackingValue]

def __init__(
self,
value: float | None = None,
attributes: dict[str, TrackingValue] | None = None,
):
self.value = value
self.attributes = attributes or {}

def add(self, key: str, value: TrackingValue) -> TrackingEventDetails:
self.attributes[key] = value
return self
26 changes: 25 additions & 1 deletion tests/provider/test_in_memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

import pytest

from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import ErrorCode
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
from openfeature.provider.in_memory_provider import (
InMemoryFlag,
InMemoryProvider,
InMemoryTrackingEvent,
)
from openfeature.track import TrackingEventDetails


def test_should_return_in_memory_provider_metadata():
Expand Down Expand Up @@ -194,3 +200,21 @@ async def test_should_resolve_object_flag_from_in_memory():
assert flag.value == return_value
assert isinstance(flag.value, dict)
assert flag.variant == "obj"


def test_should_track_event():
provider = InMemoryProvider(
{"Key": InMemoryFlag("hundred", {"zero": 0, "hundred": 100})}
)
provider.track(
tracking_event_name="test",
evaluation_context=EvaluationContext(attributes={"key": "value"}),
tracking_event_details=TrackingEventDetails(
value=1, attributes={"key": "value"}
),
)
assert provider._tracking_events == {
"test": InMemoryTrackingEvent(
value=1, details={"key": "value"}, eval_context_attributes={"key": "value"}
)
}
45 changes: 44 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
import pytest

from openfeature import api
from openfeature.api import add_hooks, clear_hooks, get_client, set_provider
from openfeature.api import (
add_hooks,
clear_hooks,
get_client,
set_evaluation_context,
set_provider,
set_transaction_context,
)
from openfeature.client import OpenFeatureClient, _typecheck_flag_value
from openfeature.evaluation_context import EvaluationContext
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
Expand Down Expand Up @@ -623,3 +630,39 @@ def test_client_should_merge_contexts():
assert context.attributes["transaction_attr"] == "transaction_value"
assert context.attributes["client_attr"] == "client_value"
assert context.attributes["invocation_attr"] == "invocation_value"


def test_client_should_track_event():
spy_provider = MagicMock(spec=NoOpProvider)
set_provider(spy_provider)
client = get_client()
client.track(tracking_event_name="test")
spy_provider.track.assert_called_once()


def test_tracking_merges_evaluation_contexts():
spy_provider = MagicMock(spec=NoOpProvider)
api.set_provider(spy_provider)
client = get_client()
set_evaluation_context(EvaluationContext("id", attributes={"key": "eval_value"}))
set_transaction_context(
EvaluationContext("id", attributes={"transaction_attr": "transaction_value"})
)
client.track(
tracking_event_name="test",
evaluation_context=EvaluationContext("id", attributes={"key": "value"}),
)
spy_provider.track.assert_called_once_with(
"test",
EvaluationContext(
"id", attributes={"transaction_attr": "transaction_value", "key": "value"}
),
None,
)


def test_should_noop_if_provider_does_not_support_tracking(monkeypatch):
provider = NoOpProvider()
set_provider(provider)
client = get_client()
client.track(tracking_event_name="test")
39 changes: 39 additions & 0 deletions tests/track/test_tracking.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from openfeature.track import TrackingEventDetails


def test_add_attribute_to_tracking_event_details():
tracking_event_details = TrackingEventDetails()
tracking_event_details.add("key", "value")
assert tracking_event_details.attributes == {"key": "value"}


def test_add_attribute_to_tracking_event_details_dict():
tracking_event_details = TrackingEventDetails()
tracking_event_details.add("key", {"key1": "value1", "key2": "value2"})
assert tracking_event_details.attributes == {
"key": {"key1": "value1", "key2": "value2"}
}


def test_get_value_from_tracking_event_details():
tracking_event_details = TrackingEventDetails(value=1)
assert tracking_event_details.value == 1


def test_get_attributes_from_tracking_event_details():
tracking_event_details = TrackingEventDetails(
value=5.0, attributes={"key": "value"}
)
assert tracking_event_details.attributes == {"key": "value"}


def test_get_attributes_from_tracking_event_details_with_none_value():
tracking_event_details = TrackingEventDetails(attributes={"key": "value"})
assert tracking_event_details.attributes == {"key": "value"}
assert tracking_event_details.value is None


def test_get_attributes_from_tracking_event_details_with_none_attributes():
tracking_event_details = TrackingEventDetails(value=5.0)
assert tracking_event_details.attributes == {}
assert tracking_event_details.value == 5.0
Loading