featureflip 2.2.0__tar.gz → 2.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {featureflip-2.2.0 → featureflip-2.3.1}/PKG-INFO +1 -1
  2. {featureflip-2.2.0 → featureflip-2.3.1}/pyproject.toml +1 -1
  3. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_core.py +48 -14
  4. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_http.py +12 -2
  5. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_polling.py +25 -2
  6. featureflip-2.3.1/src/featureflip/_streaming.py +280 -0
  7. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/client.py +4 -11
  8. featureflip-2.3.1/tests/golden/vectors.json +1044 -0
  9. {featureflip-2.2.0 → featureflip-2.3.1}/tests/integration/test_api.py +107 -0
  10. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_client.py +26 -13
  11. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_core.py +23 -0
  12. featureflip-2.3.1/tests/test_golden.py +279 -0
  13. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_http.py +32 -0
  14. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_polling.py +17 -1
  15. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_streaming.py +212 -2
  16. featureflip-2.2.0/src/featureflip/_streaming.py +0 -144
  17. {featureflip-2.2.0 → featureflip-2.3.1}/.gitignore +0 -0
  18. {featureflip-2.2.0 → featureflip-2.3.1}/CHANGELOG.md +0 -0
  19. {featureflip-2.2.0 → featureflip-2.3.1}/LICENSE +0 -0
  20. {featureflip-2.2.0 → featureflip-2.3.1}/README.md +0 -0
  21. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/__init__.py +0 -0
  22. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_events.py +0 -0
  23. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/_semver.py +0 -0
  24. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/config.py +0 -0
  25. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/context.py +0 -0
  26. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/detail.py +0 -0
  27. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/evaluation.py +0 -0
  28. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/exceptions.py +0 -0
  29. {featureflip-2.2.0 → featureflip-2.3.1}/src/featureflip/models.py +0 -0
  30. {featureflip-2.2.0 → featureflip-2.3.1}/tests/__init__.py +0 -0
  31. {featureflip-2.2.0 → featureflip-2.3.1}/tests/integration/__init__.py +0 -0
  32. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_config.py +0 -0
  33. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_context.py +0 -0
  34. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_detail.py +0 -0
  35. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_evaluation.py +0 -0
  36. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_events.py +0 -0
  37. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_exceptions.py +0 -0
  38. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_factory.py +0 -0
  39. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_models.py +0 -0
  40. {featureflip-2.2.0 → featureflip-2.3.1}/tests/test_semver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: featureflip
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: Python SDK for Featureflip - a feature flag SaaS platform
5
5
  Project-URL: Homepage, https://featureflip.io
6
6
  Project-URL: Documentation, https://featureflip.io/docs/sdks/python/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "featureflip"
7
- version = "2.2.0"
7
+ version = "2.3.1"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -37,6 +37,19 @@ logger = structlog.get_logger()
37
37
  T = TypeVar("T")
38
38
 
39
39
 
40
+ def _resolve_user_id(context: dict[str, Any]) -> str | None:
41
+ """Resolve the built-in user id from a raw context dict.
42
+
43
+ Honors the ``user_id`` / ``userId`` alias (universal across the engine and
44
+ every SDK) so a caller passing either spelling gets the same top-level
45
+ ``userId`` on analytics events. Returns ``None`` when neither is present.
46
+ """
47
+ raw = context.get("user_id")
48
+ if raw is None:
49
+ raw = context.get("userId")
50
+ return str(raw) if raw is not None else None
51
+
52
+
40
53
  class _SharedFeatureflipCore:
41
54
  """Internal shared core owning all expensive resources of a FeatureflipClient.
42
55
 
@@ -299,8 +312,13 @@ class _SharedFeatureflipCore:
299
312
  if self._http_client is None:
300
313
  return
301
314
  flags, segments = self._http_client.get_flags()
302
- self._update_flags(flags)
303
- self._update_segments(segments)
315
+ # get_flags() returns the full current snapshot, so REPLACE the store
316
+ # (drop flags/segments deleted server-side) rather than per-key
317
+ # merging — otherwise a delete only lands via a later flag.deleted
318
+ # delta or a poll/sync full-replace. Mirrors _on_polling_update.
319
+ with self._flags_lock:
320
+ self._flags = {flag.key: flag for flag in flags}
321
+ self._segments = {segment.key: segment for segment in segments}
304
322
  except Exception as e:
305
323
  logger.warning("streaming_segment_refetch_error", error=str(e))
306
324
 
@@ -323,15 +341,18 @@ class _SharedFeatureflipCore:
323
341
 
324
342
  def _start_streaming(self) -> None:
325
343
  # _start_streaming is only called from the production __init__ path,
326
- # which has already validated that sdk_key is not None.
344
+ # which has already validated that sdk_key and http_client are not None.
327
345
  assert self._sdk_key is not None
346
+ assert self._http_client is not None
328
347
  self._streaming_handler = StreamingHandler(
329
348
  sdk_key=self._sdk_key,
330
349
  config=self._config,
350
+ http_client=self._http_client,
331
351
  on_flag_updated=self._on_streaming_flag_updated,
332
352
  on_flag_deleted=self._on_streaming_flag_deleted,
333
353
  on_segment_updated=self._on_streaming_segment_updated,
334
354
  on_error=self._on_streaming_error,
355
+ on_update=self._on_polling_update,
335
356
  )
336
357
  self._streaming_handler.start()
337
358
 
@@ -365,10 +386,13 @@ class _SharedFeatureflipCore:
365
386
  """Queue a custom event."""
366
387
  if self._test_mode_values is not None or self._event_processor is None:
367
388
  return
389
+ # Send the backend SdkEventDto shape: top-level userId, no `context`.
390
+ # See apps/evaluation-api Controllers/SdkController.RecordEvents — the
391
+ # backend has no context/value/reason field, so anything else is dropped.
368
392
  event = {
369
393
  "type": "Custom",
370
394
  "flagKey": event_name,
371
- "context": context or {},
395
+ "userId": _resolve_user_id(context or {}),
372
396
  "metadata": metadata or {},
373
397
  "timestamp": datetime.now(timezone.utc).isoformat(),
374
398
  }
@@ -378,10 +402,19 @@ class _SharedFeatureflipCore:
378
402
  """Send user attributes for segment building."""
379
403
  if self._test_mode_values is not None or self._event_processor is None:
380
404
  return
405
+ # Promote the user id to the top-level userId field and put the remaining
406
+ # attributes in metadata (stripping both alias spellings so the identity
407
+ # isn't duplicated inside the metadata bag).
408
+ attributes = {
409
+ k: v
410
+ for k, v in (context or {}).items()
411
+ if k not in ("user_id", "userId")
412
+ }
381
413
  event = {
382
414
  "type": "Identify",
383
415
  "flagKey": "$identify",
384
- "context": context or {},
416
+ "userId": _resolve_user_id(context or {}),
417
+ "metadata": attributes,
385
418
  "timestamp": datetime.now(timezone.utc).isoformat(),
386
419
  }
387
420
  self._event_processor.queue_event(event)
@@ -394,24 +427,25 @@ class _SharedFeatureflipCore:
394
427
  def queue_evaluation_event(
395
428
  self,
396
429
  key: str,
397
- value: Any,
398
430
  context: dict[str, Any],
399
- reason: EvaluationReason,
400
- rule_id: str | None = None,
431
+ variation_key: str | None,
401
432
  ) -> None:
402
- """Queue an evaluation event (called by the handle's variation() method)."""
433
+ """Queue an evaluation event (called by the handle's variation() method).
434
+
435
+ Emits the backend SdkEventDto shape — top-level ``userId`` and
436
+ ``variation`` — mirroring the JS SDK. The backend has no context/value/
437
+ reason/ruleId fields, so they are not sent (System.Text.Json would drop
438
+ them and bind UserId/Variation to null; see #1920).
439
+ """
403
440
  if self._event_processor is None:
404
441
  return
405
442
  event: dict[str, Any] = {
406
443
  "type": "Evaluation",
407
444
  "flagKey": key,
408
- "value": value,
409
- "context": context,
410
- "reason": reason.value,
445
+ "userId": _resolve_user_id(context or {}),
446
+ "variation": variation_key,
411
447
  "timestamp": datetime.now(timezone.utc).isoformat(),
412
448
  }
413
- if rule_id is not None:
414
- event["ruleId"] = rule_id
415
449
  self._event_processor.queue_event(event)
416
450
 
417
451
  # --- Shutdown ---
@@ -81,10 +81,20 @@ class HttpClient:
81
81
  logger.debug("Fetching flags from API")
82
82
  response = self._client.get("/v1/sdk/flags")
83
83
  response.raise_for_status()
84
- data = response.json()
84
+ flags, segments = self.parse_flags_response(response.json())
85
+ logger.debug("Fetched flags", flag_count=len(flags), segment_count=len(segments))
86
+ return flags, segments
87
+
88
+ def parse_flags_response(
89
+ self, data: dict[str, Any]
90
+ ) -> tuple[list[FlagConfiguration], list[Segment]]:
91
+ """Parse a GET /v1/sdk/flags-shaped snapshot into models.
92
+
93
+ Reused for the connect-time ``sync`` SSE snapshot, which carries the
94
+ identical payload shape inline (no extra HTTP round-trip).
95
+ """
85
96
  flags = [self._parse_flag(f) for f in data.get("flags", [])]
86
97
  segments = [self._parse_segment(s) for s in data.get("segments", [])]
87
- logger.debug("Fetched flags", flag_count=len(flags), segment_count=len(segments))
88
98
  return flags, segments
89
99
 
90
100
  def get_flag(self, key: str) -> FlagConfiguration:
@@ -15,6 +15,11 @@ if TYPE_CHECKING:
15
15
 
16
16
  logger = structlog.get_logger()
17
17
 
18
+ # Ceiling for the fallback poller's exponential backoff during a sustained
19
+ # outage. Retry-forever, but escalate the interval so a long outage doesn't keep
20
+ # hammering the eval-api every poll_interval.
21
+ _MAX_POLL_BACKOFF_SECONDS = 300.0
22
+
18
23
 
19
24
  class HttpClientProtocol(Protocol):
20
25
  """Protocol for HTTP client to enable loose coupling."""
@@ -69,13 +74,31 @@ class PollingHandler:
69
74
  logger.info("polling_stopped")
70
75
 
71
76
  def _run(self) -> None:
72
- """Main polling loop."""
77
+ """Main polling loop.
78
+
79
+ Polls at ``poll_interval`` while healthy; on consecutive failures the
80
+ wait escalates exponentially (capped) so a sustained outage backs off
81
+ rather than hammering the eval-api at a fixed interval forever.
82
+ """
83
+ consecutive_failures = 0
73
84
  while not self._stop_event.is_set():
74
85
  try:
75
86
  flags, segments = self._http.get_flags()
76
87
  self._on_update(flags, segments)
88
+ consecutive_failures = 0
77
89
  logger.debug("polling_success", flag_count=len(flags))
78
90
  except Exception as e:
91
+ consecutive_failures += 1
79
92
  logger.warning("polling_error", error=str(e))
80
93
  self._on_error(e)
81
- self._stop_event.wait(self._config.poll_interval)
94
+ self._stop_event.wait(self._backoff_delay(consecutive_failures))
95
+
96
+ def _backoff_delay(self, consecutive_failures: int) -> float:
97
+ """Base interval on success/first failure, then capped exponential."""
98
+ if consecutive_failures <= 1:
99
+ return self._config.poll_interval
100
+ # Cap the exponent so a very high failure count can't overflow the float
101
+ # multiply; the result is capped anyway.
102
+ exponent = min(consecutive_failures - 1, 20)
103
+ delay: float = self._config.poll_interval * (2**exponent)
104
+ return min(delay, _MAX_POLL_BACKOFF_SECONDS)
@@ -0,0 +1,280 @@
1
+ """SSE streaming handler for real-time flag updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import threading
8
+ from typing import TYPE_CHECKING, Any, Protocol
9
+
10
+ import httpx
11
+ import structlog
12
+ from httpx_sse import connect_sse
13
+
14
+ from featureflip._polling import PollingHandler
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable
18
+
19
+ from featureflip.config import Config
20
+ from featureflip.models import FlagConfiguration, Segment
21
+
22
+ logger = structlog.get_logger()
23
+
24
+
25
+ class _StreamingHttpClient(Protocol):
26
+ """HTTP client surface the streaming handler needs.
27
+
28
+ Structural (duck-typed) so the concrete ``HttpClient`` satisfies it and it
29
+ can also feed the polling fallback (``get_flags``).
30
+ """
31
+
32
+ def get_flags(self) -> tuple[list[FlagConfiguration], list[Segment]]: ...
33
+
34
+ def parse_flags_response(
35
+ self, data: dict[str, Any]
36
+ ) -> tuple[list[FlagConfiguration], list[Segment]]: ...
37
+
38
+
39
+ # Reconnect backoff (mirrors go-sdk constants for cross-SDK consistency).
40
+ _RECONNECT_BASE_DELAY = 3.0 # seconds
41
+ _MAX_RECONNECT_DELAY = 30.0 # cap
42
+ _FALLBACK_THRESHOLD = 5 # consecutive stream failures before polling fallback
43
+ # Liveness watchdog: a finite SSE read timeout well above the ~30s server ping
44
+ # (≈3 missed pings). With read=None a half-open socket (silent LB/NAT idle-drop
45
+ # or partition, no FIN/RST) would block the reader forever and never reconnect;
46
+ # a finite read timeout surfaces as a ReadTimeout that _connect() catches and
47
+ # the reconnect/backoff/fallback loop handles.
48
+ _SSE_READ_TIMEOUT_SECONDS = 90.0
49
+
50
+
51
+ class StreamingHandler:
52
+ """Handles SSE streaming for real-time flag updates.
53
+
54
+ This handler connects to the Featureflip streaming endpoint and
55
+ receives real-time updates when flags are modified. It runs in a
56
+ background thread and calls the provided callbacks when events occur.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ sdk_key: str,
62
+ config: Config,
63
+ http_client: _StreamingHttpClient,
64
+ on_flag_updated: Callable[[str], None],
65
+ on_flag_deleted: Callable[[str], None],
66
+ on_segment_updated: Callable[[], None],
67
+ on_error: Callable[[Exception], None],
68
+ on_update: Callable[[list[FlagConfiguration], list[Segment]], None],
69
+ ) -> None:
70
+ """Initialize the streaming handler.
71
+
72
+ Args:
73
+ sdk_key: The SDK key for authentication.
74
+ config: Client configuration options.
75
+ http_client: HTTP client — parses the connect-time snapshot and backs the polling fallback.
76
+ on_flag_updated: Callback invoked with flag key when a flag is created or updated.
77
+ on_flag_deleted: Callback invoked with flag key when a flag is deleted.
78
+ on_segment_updated: Callback invoked when a segment is updated (triggers full refetch).
79
+ on_error: Callback invoked when an error occurs.
80
+ on_update: Callback invoked with a full (flags, segments) snapshot — a full store replace.
81
+ """
82
+ self._sdk_key = sdk_key
83
+ self._config = config
84
+ self._http = http_client
85
+ self._on_flag_updated = on_flag_updated
86
+ self._on_flag_deleted = on_flag_deleted
87
+ self._on_segment_updated = on_segment_updated
88
+ self._on_error = on_error
89
+ self._on_update = on_update
90
+ self._stop_event = threading.Event()
91
+ self._thread: threading.Thread | None = None
92
+ # Reconnect tunables (defaulted from module constants; tests shrink them).
93
+ self._reconnect_base_delay = _RECONNECT_BASE_DELAY
94
+ self._max_reconnect_delay = _MAX_RECONNECT_DELAY
95
+ self._fallback_threshold = _FALLBACK_THRESHOLD
96
+ self._fallback_lock = threading.Lock()
97
+ self._fallback_poller: PollingHandler | None = None
98
+
99
+ def start(self) -> None:
100
+ """Start the streaming connection in a background thread."""
101
+ self._stop_event.clear()
102
+ self._thread = threading.Thread(target=self._run, daemon=True)
103
+ self._thread.start()
104
+ logger.info("streaming_started")
105
+
106
+ def stop(self) -> None:
107
+ """Stop the streaming connection."""
108
+ # Set the stop event BEFORE reaping so a concurrent
109
+ # _start_fallback_polling (which checks the event under the lock) can't
110
+ # leak a poller.
111
+ self._stop_event.set()
112
+ self._stop_fallback_polling()
113
+ if self._thread and self._thread.is_alive():
114
+ self._thread.join(timeout=5.0)
115
+ logger.info("streaming_stopped")
116
+
117
+ def _run(self) -> None:
118
+ """Reconnect forever with capped exponential backoff.
119
+
120
+ Never gives up while the handler is alive: a clean EOF or any error
121
+ both back off before reconnecting (no busy-loop). After
122
+ ``_fallback_threshold`` consecutive failures, also starts a polling
123
+ fallback (see _start_fallback_polling); the stream keeps retrying.
124
+ """
125
+ consecutive_failures = 0
126
+ while not self._stop_event.is_set():
127
+ reached = self._connect()
128
+ if self._stop_event.is_set():
129
+ break
130
+ if reached:
131
+ consecutive_failures = 0
132
+ self._stop_fallback_polling()
133
+ else:
134
+ consecutive_failures += 1
135
+ if consecutive_failures >= self._fallback_threshold:
136
+ self._start_fallback_polling()
137
+ self._stop_event.wait(self._backoff_delay(consecutive_failures))
138
+
139
+ def _connect(self) -> bool:
140
+ """Connect to the SSE stream and process events.
141
+
142
+ Returns True if a live stream was established (at least one event
143
+ received — the server always sends a ``sync`` frame first), False if
144
+ the connection could not be established.
145
+ """
146
+ url = self._get_stream_url()
147
+ headers = self._get_headers()
148
+ reached = False
149
+ try:
150
+ with (
151
+ httpx.Client(
152
+ timeout=httpx.Timeout(
153
+ connect=self._config.connect_timeout,
154
+ # Finite read timeout as a liveness watchdog — see
155
+ # _SSE_READ_TIMEOUT_SECONDS. Healthy streams reset it on
156
+ # every event/ping (~30s), so it only fires on a
157
+ # half-open socket, triggering a reconnect.
158
+ read=_SSE_READ_TIMEOUT_SECONDS,
159
+ write=self._config.read_timeout,
160
+ pool=self._config.connect_timeout,
161
+ ),
162
+ ) as client,
163
+ connect_sse(client, "GET", url, headers=headers) as event_source,
164
+ ):
165
+ logger.debug("sse_connected", url=url)
166
+ for event in event_source.iter_sse():
167
+ if not reached:
168
+ reached = True
169
+ # The stream is live (the server sends a `sync` frame
170
+ # first). Reap the fallback poller now so a healthy
171
+ # stream is the sole data source. _connect() blocks in
172
+ # iter_sse() for the whole lifetime of a healthy stream,
173
+ # so reaping only after it returns (in _run) would leave
174
+ # the poller running for the entire healthy period — its
175
+ # periodic full-store replaces can revert an SSE delta
176
+ # applied mid-flight (stale value flapping).
177
+ self._stop_fallback_polling()
178
+ if self._stop_event.is_set():
179
+ break
180
+ self._handle_event(event.event, event.data)
181
+ except Exception as e:
182
+ logger.warning("streaming_error", error=str(e))
183
+ self._on_error(e)
184
+ return reached
185
+
186
+ def _backoff_delay(self, failures: int) -> float:
187
+ """Capped exponential backoff.
188
+
189
+ Base for the first (re)connect, then doubling up to the cap, with
190
+ [d/2, d) jitter once escalating (thundering-herd avoidance).
191
+ """
192
+ if failures <= 1:
193
+ return self._reconnect_base_delay
194
+ delay = self._reconnect_base_delay
195
+ for _ in range(1, failures):
196
+ delay *= 2
197
+ if delay >= self._max_reconnect_delay:
198
+ return self._with_jitter(self._max_reconnect_delay)
199
+ return self._with_jitter(delay)
200
+
201
+ def _with_jitter(self, delay: float) -> float:
202
+ """Return a value in [delay/2, delay)."""
203
+ half = delay / 2
204
+ return half + random.random() * half
205
+
206
+ def _start_fallback_polling(self) -> None:
207
+ """Start a parallel polling fallback (full re-fetch, retries forever).
208
+
209
+ Idempotent. A start racing a concurrent stop() is a no-op: stop() sets
210
+ the stop event before reaping, so a set event under the lock means a
211
+ post-stop start would leak a poller — bail instead.
212
+ """
213
+ with self._fallback_lock:
214
+ if self._fallback_poller is not None or self._stop_event.is_set():
215
+ return
216
+ poller = PollingHandler(
217
+ http_client=self._http,
218
+ config=self._config,
219
+ on_update=self._on_update,
220
+ on_error=self._on_error,
221
+ )
222
+ poller.start()
223
+ self._fallback_poller = poller
224
+ logger.warning("streaming_fallback_polling_started")
225
+
226
+ def _stop_fallback_polling(self) -> None:
227
+ with self._fallback_lock:
228
+ if self._fallback_poller is not None:
229
+ self._fallback_poller.stop()
230
+ self._fallback_poller = None
231
+ logger.info("streaming_fallback_polling_stopped")
232
+
233
+ def _get_stream_url(self) -> str:
234
+ """Get the streaming endpoint URL.
235
+
236
+ Returns:
237
+ The full URL for the SSE stream endpoint.
238
+ """
239
+ return f"{self._config.base_url}/v1/sdk/stream"
240
+
241
+ def _get_headers(self) -> dict[str, str]:
242
+ """Get the headers for the streaming request.
243
+
244
+ Returns:
245
+ Dictionary of HTTP headers.
246
+ """
247
+ return {
248
+ "Authorization": self._sdk_key,
249
+ "User-Agent": "featureflip-python/0.1.0",
250
+ "Accept": "text/event-stream",
251
+ }
252
+
253
+ def _handle_event(self, event_type: str, data: str) -> None:
254
+ """Handle an SSE event."""
255
+ try:
256
+ if event_type in ("flag.created", "flag.updated"):
257
+ payload = json.loads(data)
258
+ key = payload.get("key")
259
+ if key:
260
+ self._on_flag_updated(key)
261
+ logger.debug("flag_updated_via_sse", key=key)
262
+ elif event_type == "flag.deleted":
263
+ payload = json.loads(data)
264
+ key = payload.get("key")
265
+ if key:
266
+ self._on_flag_deleted(key)
267
+ logger.debug("flag_deleted_via_sse", key=key)
268
+ elif event_type == "segment.updated":
269
+ self._on_segment_updated()
270
+ logger.debug("segment_updated_via_sse")
271
+ elif event_type == "sync":
272
+ # Full config snapshot the server sends on (re)connect. Replace
273
+ # the whole store so flags changed OR deleted during a disconnect
274
+ # are re-synced. Full replace, never a per-key merge.
275
+ flags, segments = self._http.parse_flags_response(json.loads(data))
276
+ self._on_update(flags, segments)
277
+ logger.debug("sync_snapshot_applied", flag_count=len(flags))
278
+ # Unknown events are ignored silently
279
+ except Exception as e:
280
+ logger.error("event_parse_error", error=str(e), event_type=event_type)
@@ -103,10 +103,8 @@ class FeatureflipClient:
103
103
  if track:
104
104
  self._core.queue_evaluation_event(
105
105
  key=key,
106
- value=value,
107
106
  context=context or {},
108
- reason=detail.reason,
109
- rule_id=detail.rule_id,
107
+ variation_key=detail.variation_key,
110
108
  )
111
109
  return cast("T", value)
112
110
  except Exception as e:
@@ -254,20 +252,15 @@ class FeatureflipClient:
254
252
  def _queue_evaluation_event(
255
253
  self,
256
254
  key: str,
257
- value: Any,
258
255
  context: dict[str, Any],
259
- reason: EvaluationReason,
260
- rule_id: str | None = None,
256
+ variation_key: str | None = None,
261
257
  ) -> None:
262
258
  """Queue an evaluation event. Delegates to the shared core.
263
259
 
264
- Preserved for backwards compatibility with existing tests that call
265
- this method directly on the client.
260
+ Preserved for tests that call this method directly on the client.
266
261
  """
267
262
  self._core.queue_evaluation_event(
268
263
  key=key,
269
- value=value,
270
264
  context=context,
271
- reason=reason,
272
- rule_id=rule_id,
265
+ variation_key=variation_key,
273
266
  )