Skip to content

Commit 7307b83

Browse files
feat: experimentation surface — track_event, track_exposure_event, get_experiment_flag (#220)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 57dc7f4 commit 7307b83

8 files changed

Lines changed: 438 additions & 369 deletions

File tree

flagsmith/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from flagsmith import webhooks
2-
from flagsmith.analytics import PipelineAnalyticsConfig
2+
from flagsmith.analytics import EventProcessorConfig
33
from flagsmith.flagsmith import Flagsmith
44
from flagsmith.version import __version__
55

6-
__all__ = ("Flagsmith", "PipelineAnalyticsConfig", "webhooks", "__version__")
6+
__all__ = ("Flagsmith", "EventProcessorConfig", "webhooks", "__version__")

flagsmith/analytics.py

Lines changed: 54 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import json
33
import logging
44
import threading
5-
import time
65
import typing
76
from dataclasses import dataclass
87
from datetime import datetime
@@ -18,6 +17,10 @@
1817
# Used to control how often we send data(in seconds)
1918
ANALYTICS_TIMER: typing.Final[int] = 10
2019

20+
FLAG_EXPOSURE_EVENT: typing.Final[str] = "$flag_exposure"
21+
22+
DEFAULT_EVENT_API_URL: typing.Final[str] = "https://events.api.flagsmith.com/"
23+
2124
session = FuturesSession(max_workers=4)
2225

2326

@@ -72,92 +75,90 @@ def track_feature(self, feature_name: str) -> None:
7275

7376

7477
@dataclass
75-
class PipelineAnalyticsConfig:
76-
analytics_server_url: str
78+
class EventProcessorConfig:
79+
events_api_url: str = DEFAULT_EVENT_API_URL
7780
max_buffer_items: int = 1000
7881
flush_interval_seconds: float = 10.0
7982

8083

81-
class PipelineAnalyticsProcessor:
84+
class EventProcessor:
8285
"""
83-
Buffered analytics processor that sends per-evaluation and custom events
84-
to the Flagsmith pipeline analytics endpoint in batches.
85-
86-
Evaluation events are deduplicated within each flush window. Events are
87-
flushed periodically via a background timer or when the buffer is full.
86+
Buffered event processor that batches custom events and POSTs them to the
87+
Flagsmith event endpoint. Flushes on a background timer or when the buffer
88+
fills.
8889
"""
8990

9091
def __init__(
9192
self,
92-
config: PipelineAnalyticsConfig,
93+
config: EventProcessorConfig,
9394
environment_key: str,
9495
) -> None:
95-
url = config.analytics_server_url
96+
url = config.events_api_url
9697
if not url.endswith("/"):
9798
url = f"{url}/"
98-
self._batch_endpoint = f"{url}v1/analytics/batch"
99+
self._batch_endpoint = f"{url}v1/events"
99100
self._environment_key = environment_key
100101
self._max_buffer = config.max_buffer_items
101102
self._flush_interval_seconds = config.flush_interval_seconds
102103

103104
self._buffer: typing.List[typing.Dict[str, typing.Any]] = []
104-
self._dedup_keys: typing.Dict[str, str] = {}
105105
self._lock = threading.Lock()
106106
self._timer: typing.Optional[threading.Timer] = None
107107

108-
def record_evaluation_event(
108+
def track_event(
109109
self,
110-
flag_key: str,
111-
enabled: bool,
112-
value: typing.Any,
113-
identity_identifier: typing.Optional[str] = None,
110+
event: str,
111+
identifier: typing.Optional[str] = None,
112+
value: typing.Optional[typing.Union[str, int, float, bool]] = None,
114113
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
114+
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None,
115115
) -> None:
116-
fingerprint = f"{identity_identifier or 'none'}|{enabled}|{value}"
117-
should_flush = False
118-
119-
with self._lock:
120-
if self._dedup_keys.get(flag_key) == fingerprint:
121-
return
122-
self._dedup_keys[flag_key] = fingerprint
123-
self._buffer.append(
124-
{
125-
"event_id": flag_key,
126-
"event_type": "flag_evaluation",
127-
"evaluated_at": int(time.time() * 1000),
128-
"identity_identifier": identity_identifier,
129-
"enabled": enabled,
130-
"value": value,
131-
"traits": dict(traits) if traits else None,
132-
"metadata": {"sdk_version": __version__},
133-
}
134-
)
135-
if len(self._buffer) >= self._max_buffer:
136-
should_flush = True
137-
138-
if should_flush:
139-
self.flush()
116+
self._buffer_event(
117+
event=event,
118+
feature_name=None,
119+
identifier=identifier,
120+
value=value,
121+
traits=traits,
122+
metadata=metadata,
123+
)
140124

141-
def record_custom_event(
125+
def track_exposure_event(
142126
self,
143-
event_name: str,
144-
identity_identifier: typing.Optional[str] = None,
127+
feature_name: str,
128+
identifier: typing.Optional[str] = None,
129+
value: typing.Optional[typing.Union[str, int, float, bool]] = None,
145130
traits: typing.Optional[typing.Dict[str, typing.Any]] = None,
146131
metadata: typing.Optional[typing.Dict[str, typing.Any]] = None,
147132
) -> None:
148-
should_flush = False
133+
self._buffer_event(
134+
event=FLAG_EXPOSURE_EVENT,
135+
feature_name=feature_name,
136+
identifier=identifier,
137+
value=value,
138+
traits=traits,
139+
metadata=metadata,
140+
)
149141

142+
def _buffer_event(
143+
self,
144+
event: str,
145+
feature_name: typing.Optional[str],
146+
identifier: typing.Optional[str],
147+
value: typing.Optional[typing.Union[str, int, float, bool]],
148+
traits: typing.Optional[typing.Dict[str, typing.Any]],
149+
metadata: typing.Optional[typing.Dict[str, typing.Any]],
150+
) -> None:
151+
should_flush = False
150152
with self._lock:
151153
self._buffer.append(
152154
{
153-
"event_id": event_name,
154-
"event_type": "custom_event",
155-
"evaluated_at": int(time.time() * 1000),
156-
"identity_identifier": identity_identifier,
157-
"enabled": None,
158-
"value": None,
155+
"event": event,
156+
"feature_name": feature_name,
157+
"identifier": identifier,
158+
"value": str(value) if value is not None else None,
159159
"traits": dict(traits) if traits else None,
160160
"metadata": {**(metadata or {}), "sdk_version": __version__},
161+
"timestamp": int(datetime.now().timestamp() * 1000),
161162
}
162163
)
163164
if len(self._buffer) >= self._max_buffer:
@@ -172,11 +173,8 @@ def flush(self) -> None:
172173
return
173174
events = self._buffer
174175
self._buffer = []
175-
self._dedup_keys.clear()
176176

177-
payload = json.dumps(
178-
{"events": events, "environment_key": self._environment_key}
179-
)
177+
payload = json.dumps({"events": events})
180178
try:
181179
future = session.post(
182180
self._batch_endpoint,

0 commit comments

Comments
 (0)