Skip to content

Commit b81ee21

Browse files
authored
feat: Implement tracking (#564)
* add: Implement tracking Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: update README.md Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * add: track method to providers Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * add: add TrackingEventDetails definition Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * add: add tests Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * fix: add newlines Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: update test decorators Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: add docstring for track method Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * refactor: rename to eval_context_attributes Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * refactor: better naming for tracking events for inmemory provider Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: add Tracking to list of features in README Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: fix example syntax in README Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * feat: add track method to base FeatureProvider class Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: remove unnecessary async from tests Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: fix typing Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: fix typo in readme Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * fix: use hasattr to check for track method Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: fix example in README Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: run pre-commit Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * remove unnecessary track definitions Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * fix: remove unused import Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * fix: remove unnecessary async Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * chore: use correct import path in README Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> * fix: deduplicate tracking_event_details checks Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com> --------- Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com>
1 parent fafd902 commit b81ee21

File tree

8 files changed

+242
-4
lines changed

8 files changed

+242
-4
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ print("Value: " + str(flag_value))
107107
|| [Logging](#logging) | Integrate with popular logging packages. |
108108
|| [Domains](#domains) | Logically bind clients with providers. |
109109
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
110+
|| [Tracking](#tracking) | Associate user actions with feature flag evaluations. |
110111
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
111112
|| [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) |
112113
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
@@ -185,6 +186,27 @@ client.add_hooks([MyHook()])
185186
options = FlagEvaluationOptions(hooks=[MyHook()])
186187
client.get_boolean_flag("my-flag", False, flag_evaluation_options=options)
187188
```
189+
### Tracking
190+
191+
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.
192+
This is essential for robust experimentation powered by feature flags.
193+
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.
194+
195+
```python
196+
from openfeature.track import TrackingEventDetails
197+
198+
# initialize a client
199+
client = api.get_client()
200+
201+
# trigger tracking event action
202+
client.track(
203+
'visited-promo-page',
204+
evaluation_context=EvaluationContext(),
205+
tracking_event_details=TrackingEventDetails(99.77).add("currencyCode", "USD"),
206+
)
207+
```
208+
209+
Note that some providers may not support tracking; check the documentation for your provider for more information.
188210

189211
### Logging
190212

openfeature/client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from openfeature.provider import FeatureProvider, ProviderStatus
3434
from openfeature.provider._registry import provider_registry
35+
from openfeature.track import TrackingEventDetails
3536
from openfeature.transaction_context import get_transaction_context
3637

3738
__all__ = [
@@ -955,6 +956,33 @@ def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
955956
def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
956957
_event_support.remove_client_handler(self, event, handler)
957958

959+
def track(
960+
self,
961+
tracking_event_name: str,
962+
evaluation_context: EvaluationContext | None = None,
963+
tracking_event_details: TrackingEventDetails | None = None,
964+
) -> None:
965+
"""
966+
Tracks the occurrence of a particular action or application state.
967+
968+
:param tracking_event_name: the name of the tracking event
969+
:param evaluation_context: the evaluation context
970+
:param tracking_event_details: Optional data relevant to the tracking event
971+
"""
972+
973+
if evaluation_context is None:
974+
evaluation_context = EvaluationContext()
975+
976+
merged_eval_context = (
977+
get_evaluation_context()
978+
.merge(get_transaction_context())
979+
.merge(self.context)
980+
.merge(evaluation_context)
981+
)
982+
self.provider.track(
983+
tracking_event_name, merged_eval_context, tracking_event_details
984+
)
985+
958986

959987
def _typecheck_flag_value(
960988
value: typing.Any, flag_type: FlagType

openfeature/provider/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from openfeature.event import ProviderEvent, ProviderEventDetails
1010
from openfeature.flag_evaluation import FlagResolutionDetails
1111
from openfeature.hook import Hook
12+
from openfeature.track import TrackingEventDetails
1213

1314
from .metadata import Metadata
1415

@@ -116,6 +117,13 @@ async def resolve_object_details_async(
116117
Sequence[FlagValueType] | Mapping[str, FlagValueType]
117118
]: ...
118119

120+
def track(
121+
self,
122+
tracking_event_name: str,
123+
evaluation_context: EvaluationContext | None = None,
124+
tracking_event_details: TrackingEventDetails | None = None,
125+
) -> None: ...
126+
119127

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

149+
def track(
150+
self,
151+
tracking_event_name: str,
152+
evaluation_context: EvaluationContext | None = None,
153+
tracking_event_details: TrackingEventDetails | None = None,
154+
) -> None:
155+
pass
156+
141157
@abstractmethod
142158
def get_metadata(self) -> Metadata:
143159
pass

openfeature/provider/in_memory_provider.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from dataclasses import dataclass, field
66

77
from openfeature._backports.strenum import StrEnum
8-
from openfeature.evaluation_context import EvaluationContext
8+
from openfeature.evaluation_context import EvaluationContext, EvaluationContextAttribute
99
from openfeature.exception import ErrorCode
1010
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
1111
from openfeature.provider import AbstractProvider, Metadata
12+
from openfeature.track import TrackingEventDetails
1213

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

2425

26+
@dataclass
27+
class InMemoryTrackingEvent:
28+
value: float | None = None
29+
details: dict[str, typing.Any] = field(default_factory=dict)
30+
eval_context_attributes: Mapping[str, EvaluationContextAttribute] = field(
31+
default_factory=dict
32+
)
33+
34+
2535
T_co = typing.TypeVar("T_co", covariant=True)
2636

2737

@@ -58,14 +68,24 @@ def resolve(
5868

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

71+
TrackingStorage = dict[str, InMemoryTrackingEvent]
72+
6173
V = typing.TypeVar("V")
6274

6375

6476
class InMemoryProvider(AbstractProvider):
6577
_flags: FlagStorage
78+
_tracking_events: TrackingStorage
6679

67-
def __init__(self, flags: FlagStorage) -> None:
80+
# tracking_events defaults to an empty dict
81+
def __init__(
82+
self, flags: FlagStorage, tracking_events: TrackingStorage | None = None
83+
) -> None:
6884
self._flags = flags.copy()
85+
if tracking_events is not None:
86+
self._tracking_events = tracking_events.copy()
87+
else:
88+
self._tracking_events = {}
6989

7090
def get_metadata(self) -> Metadata:
7191
return InMemoryMetadata()
@@ -176,3 +196,24 @@ async def _resolve_async(
176196
evaluation_context: EvaluationContext | None,
177197
) -> FlagResolutionDetails[V]:
178198
return self._resolve(flag_key, default_value, evaluation_context)
199+
200+
def track(
201+
self,
202+
tracking_event_name: str,
203+
evaluation_context: EvaluationContext | None = None,
204+
tracking_event_details: TrackingEventDetails | None = None,
205+
) -> None:
206+
details = {}
207+
value = None
208+
if tracking_event_details:
209+
details = tracking_event_details.attributes
210+
value = tracking_event_details.value
211+
eval_context_attributes = (
212+
evaluation_context.attributes if evaluation_context is not None else {}
213+
)
214+
215+
self._tracking_events[tracking_event_name] = InMemoryTrackingEvent(
216+
value=value,
217+
details=details,
218+
eval_context_attributes=eval_context_attributes,
219+
)

openfeature/track/__init__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
from collections.abc import Mapping, Sequence
5+
6+
TrackingValue: typing.TypeAlias = (
7+
bool | int | float | str | Sequence["TrackingValue"] | Mapping[str, "TrackingValue"]
8+
)
9+
10+
11+
class TrackingEventDetails:
12+
value: float | None
13+
attributes: dict[str, TrackingValue]
14+
15+
def __init__(
16+
self,
17+
value: float | None = None,
18+
attributes: dict[str, TrackingValue] | None = None,
19+
):
20+
self.value = value
21+
self.attributes = attributes or {}
22+
23+
def add(self, key: str, value: TrackingValue) -> TrackingEventDetails:
24+
self.attributes[key] = value
25+
return self

tests/provider/test_in_memory_provider.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
import pytest
44

5+
from openfeature.evaluation_context import EvaluationContext
56
from openfeature.exception import ErrorCode
67
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
7-
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
8+
from openfeature.provider.in_memory_provider import (
9+
InMemoryFlag,
10+
InMemoryProvider,
11+
InMemoryTrackingEvent,
12+
)
13+
from openfeature.track import TrackingEventDetails
814

915

1016
def test_should_return_in_memory_provider_metadata():
@@ -194,3 +200,21 @@ async def test_should_resolve_object_flag_from_in_memory():
194200
assert flag.value == return_value
195201
assert isinstance(flag.value, dict)
196202
assert flag.variant == "obj"
203+
204+
205+
def test_should_track_event():
206+
provider = InMemoryProvider(
207+
{"Key": InMemoryFlag("hundred", {"zero": 0, "hundred": 100})}
208+
)
209+
provider.track(
210+
tracking_event_name="test",
211+
evaluation_context=EvaluationContext(attributes={"key": "value"}),
212+
tracking_event_details=TrackingEventDetails(
213+
value=1, attributes={"key": "value"}
214+
),
215+
)
216+
assert provider._tracking_events == {
217+
"test": InMemoryTrackingEvent(
218+
value=1, details={"key": "value"}, eval_context_attributes={"key": "value"}
219+
)
220+
}

tests/test_client.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
import pytest
99

1010
from openfeature import api
11-
from openfeature.api import add_hooks, clear_hooks, get_client, set_provider
11+
from openfeature.api import (
12+
add_hooks,
13+
clear_hooks,
14+
get_client,
15+
set_evaluation_context,
16+
set_provider,
17+
set_transaction_context,
18+
)
1219
from openfeature.client import OpenFeatureClient, _typecheck_flag_value
1320
from openfeature.evaluation_context import EvaluationContext
1421
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
@@ -623,3 +630,39 @@ def test_client_should_merge_contexts():
623630
assert context.attributes["transaction_attr"] == "transaction_value"
624631
assert context.attributes["client_attr"] == "client_value"
625632
assert context.attributes["invocation_attr"] == "invocation_value"
633+
634+
635+
def test_client_should_track_event():
636+
spy_provider = MagicMock(spec=NoOpProvider)
637+
set_provider(spy_provider)
638+
client = get_client()
639+
client.track(tracking_event_name="test")
640+
spy_provider.track.assert_called_once()
641+
642+
643+
def test_tracking_merges_evaluation_contexts():
644+
spy_provider = MagicMock(spec=NoOpProvider)
645+
api.set_provider(spy_provider)
646+
client = get_client()
647+
set_evaluation_context(EvaluationContext("id", attributes={"key": "eval_value"}))
648+
set_transaction_context(
649+
EvaluationContext("id", attributes={"transaction_attr": "transaction_value"})
650+
)
651+
client.track(
652+
tracking_event_name="test",
653+
evaluation_context=EvaluationContext("id", attributes={"key": "value"}),
654+
)
655+
spy_provider.track.assert_called_once_with(
656+
"test",
657+
EvaluationContext(
658+
"id", attributes={"transaction_attr": "transaction_value", "key": "value"}
659+
),
660+
None,
661+
)
662+
663+
664+
def test_should_noop_if_provider_does_not_support_tracking(monkeypatch):
665+
provider = NoOpProvider()
666+
set_provider(provider)
667+
client = get_client()
668+
client.track(tracking_event_name="test")

tests/track/test_tracking.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from openfeature.track import TrackingEventDetails
2+
3+
4+
def test_add_attribute_to_tracking_event_details():
5+
tracking_event_details = TrackingEventDetails()
6+
tracking_event_details.add("key", "value")
7+
assert tracking_event_details.attributes == {"key": "value"}
8+
9+
10+
def test_add_attribute_to_tracking_event_details_dict():
11+
tracking_event_details = TrackingEventDetails()
12+
tracking_event_details.add("key", {"key1": "value1", "key2": "value2"})
13+
assert tracking_event_details.attributes == {
14+
"key": {"key1": "value1", "key2": "value2"}
15+
}
16+
17+
18+
def test_get_value_from_tracking_event_details():
19+
tracking_event_details = TrackingEventDetails(value=1)
20+
assert tracking_event_details.value == 1
21+
22+
23+
def test_get_attributes_from_tracking_event_details():
24+
tracking_event_details = TrackingEventDetails(
25+
value=5.0, attributes={"key": "value"}
26+
)
27+
assert tracking_event_details.attributes == {"key": "value"}
28+
29+
30+
def test_get_attributes_from_tracking_event_details_with_none_value():
31+
tracking_event_details = TrackingEventDetails(attributes={"key": "value"})
32+
assert tracking_event_details.attributes == {"key": "value"}
33+
assert tracking_event_details.value is None
34+
35+
36+
def test_get_attributes_from_tracking_event_details_with_none_attributes():
37+
tracking_event_details = TrackingEventDetails(value=5.0)
38+
assert tracking_event_details.attributes == {}
39+
assert tracking_event_details.value == 5.0

0 commit comments

Comments
 (0)