featureflip 2.3.0__tar.gz → 2.4.0__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 (41) hide show
  1. {featureflip-2.3.0 → featureflip-2.4.0}/CHANGELOG.md +39 -4
  2. {featureflip-2.3.0 → featureflip-2.4.0}/PKG-INFO +1 -1
  3. {featureflip-2.3.0 → featureflip-2.4.0}/pyproject.toml +1 -1
  4. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/__init__.py +3 -0
  5. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_core.py +149 -14
  6. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/client.py +34 -14
  7. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/config.py +13 -1
  8. featureflip-2.4.0/src/featureflip/inspector.py +72 -0
  9. {featureflip-2.3.0 → featureflip-2.4.0}/tests/integration/test_api.py +107 -0
  10. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_client.py +26 -13
  11. featureflip-2.4.0/tests/test_inspector.py +578 -0
  12. {featureflip-2.3.0 → featureflip-2.4.0}/.gitignore +0 -0
  13. {featureflip-2.3.0 → featureflip-2.4.0}/LICENSE +0 -0
  14. {featureflip-2.3.0 → featureflip-2.4.0}/README.md +0 -0
  15. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_events.py +0 -0
  16. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_http.py +0 -0
  17. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_polling.py +0 -0
  18. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_semver.py +0 -0
  19. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_streaming.py +0 -0
  20. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/context.py +0 -0
  21. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/detail.py +0 -0
  22. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/evaluation.py +0 -0
  23. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/exceptions.py +0 -0
  24. {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/models.py +0 -0
  25. {featureflip-2.3.0 → featureflip-2.4.0}/tests/__init__.py +0 -0
  26. {featureflip-2.3.0 → featureflip-2.4.0}/tests/golden/vectors.json +0 -0
  27. {featureflip-2.3.0 → featureflip-2.4.0}/tests/integration/__init__.py +0 -0
  28. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_config.py +0 -0
  29. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_context.py +0 -0
  30. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_core.py +0 -0
  31. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_detail.py +0 -0
  32. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_evaluation.py +0 -0
  33. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_events.py +0 -0
  34. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_exceptions.py +0 -0
  35. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_factory.py +0 -0
  36. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_golden.py +0 -0
  37. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_http.py +0 -0
  38. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_models.py +0 -0
  39. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_polling.py +0 -0
  40. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_semver.py +0 -0
  41. {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_streaming.py +0 -0
@@ -1,16 +1,51 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2.4.0 — 2026-07-29
4
4
 
5
5
  ### Added
6
6
 
7
- - **Semantic-version condition operators.** Five new operators — `SemverEquals`, `SemverGreaterThan`, `SemverGreaterThanOrEqual`, `SemverLessThan`, `SemverLessThanOrEqual` — compare an attribute against the condition value(s) as a semantic version (https://semver.org) rather than a decimal, so `2.10.1` correctly satisfies `>= 2.0` (the numeric path mis-parsed multi-segment versions). Tolerant of a leading `v`, `+build` metadata, missing trailing segments (`2.0` == `2.0.0`), and `-prerelease` precedence; unparseable versions match nothing. A rule matches when any supplied condition value satisfies the operator, mirroring the numeric/date operators and the evaluation engine. New module: `featureflip/_semver.py`.
7
+ - **`onEvaluation` inspector callback.** `inspectors` config option registering in-process observers fired on every evaluation, receiving flag key, context, value, variation key, reason, rule id and prerequisite key. Also threaded through `FeatureflipClient.for_testing(flags, inspectors=...)`, which bypasses `__init__` (#1914).
8
8
 
9
- - **Prerequisite flag support.** Flags can declare other flags as prerequisites; the flag's rules and fallthrough only run when every prerequisite serves the expected variation, otherwise the off variation is served with `EvaluationReason.PREREQUISITE_FAILED` and `prerequisite_key` set on the `EvaluationDetail`. Resolution is recursive (depth-capped at 10) with per-call memoisation. New types: `Prerequisite`, `EvaluationReason.PREREQUISITE_FAILED`. New `EvaluationDetail` fields: `variation_key`, `prerequisite_key`. New batch-eval entry point: `FlagEvaluator.evaluate_with_shared_memo(...)`.
9
+ ### Fixed
10
+
11
+ - A served variation key the flag does not define now reports reason `ERROR` with the caller's default, instead of a misleading success reason (#1989).
12
+
13
+ ## 2.3.1 — 2026-07-14
14
+
15
+ ### Fixed
16
+
17
+ - Analytics events are sent in the backend's `SdkEventDto` wire shape (#1920).
18
+
19
+ ## 2.3.0 — 2026-07-13
20
+
21
+ ### Fixed
22
+
23
+ - Outage-recovery hardening: polling fallback, reconnect backoff, and SSE `sync` applied as a full store replace (#1868, #1896).
24
+ - The streaming fallback poller is reaped once the stream is healthy, instead of continuing to poll alongside it (#1895).
25
+
26
+ ## 2.2.0 — 2026-06-19
27
+
28
+ ### Added
29
+
30
+ - **Semantic-version condition operators.** Five new operators — `SemverEquals`, `SemverGreaterThan`, `SemverGreaterThanOrEqual`, `SemverLessThan`, `SemverLessThanOrEqual` — compare an attribute against the condition value(s) as a semantic version (https://semver.org) rather than a decimal, so `2.10.1` correctly satisfies `>= 2.0` (the numeric path mis-parsed multi-segment versions). Tolerant of a leading `v`, `+build` metadata, missing trailing segments (`2.0` == `2.0.0`), and `-prerelease` precedence; unparseable versions match nothing. A rule matches when any supplied condition value satisfies the operator, mirroring the numeric/date operators and the evaluation engine. New module: `featureflip/_semver.py` (#1409).
10
31
 
11
32
  ### Fixed
12
33
 
13
- - **Multi-word condition operators sent by the server now parse correctly.** The evaluation API serializes operators in PascalCase (e.g. `GreaterThan`, `SemverGreaterThanOrEqual`); operator parsing previously lower-cased the wire string, which collapsed the word boundaries (`GreaterThan` → `greaterthan`) and failed to resolve any multi-word operator. `ConditionOperator` now normalizes PascalCase → snake_case via `_missing_`.
34
+ - **Multi-word condition operators sent by the server now parse correctly.** The evaluation API serializes operators in PascalCase (e.g. `GreaterThan`, `SemverGreaterThanOrEqual`); operator parsing previously lower-cased the wire string, which collapsed the word boundaries (`GreaterThan` → `greaterthan`) and failed to resolve any multi-word operator. `ConditionOperator` now normalizes PascalCase → snake_case via `_missing_` (#1429).
35
+ - Relational operators match if the attribute satisfies the operator against **any** supplied condition value (#1443).
36
+ - `MatchesRegex` and semver prerelease comparison are now case-sensitive, matching the engine (#1453, #1454).
37
+ - `Before`/`After` date operators aligned with the engine (#1455).
38
+ - Type-aware numeric coercion for `Equals`/`In` (#1458).
39
+ - Keyless rollouts serve the control variation deterministically (#1457).
40
+ - Segment-keyed rules with no segment source fail closed (#1459).
41
+ - Present-but-null attributes are treated as absent (#1460).
42
+ - Environment-level percentage rollouts with no variations no longer throw (#1469).
43
+
44
+ ## 2.1.0 — 2026-05-27
45
+
46
+ ### Added
47
+
48
+ - **Prerequisite flag support.** Flags can declare other flags as prerequisites; the flag's rules and fallthrough only run when every prerequisite serves the expected variation, otherwise the off variation is served with `EvaluationReason.PREREQUISITE_FAILED` and `prerequisite_key` set on the `EvaluationDetail`. Resolution is recursive (depth-capped at 10) with per-call memoisation. New types: `Prerequisite`, `EvaluationReason.PREREQUISITE_FAILED`. New `EvaluationDetail` fields: `variation_key`, `prerequisite_key`. New batch-eval entry point: `FlagEvaluator.evaluate_with_shared_memo(...)` (#1108).
14
49
 
15
50
  ## 2.0.0 — 2026-04-08
16
51
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: featureflip
3
- Version: 2.3.0
3
+ Version: 2.4.0
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.3.0"
7
+ version = "2.4.0"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -11,6 +11,7 @@ from featureflip.exceptions import (
11
11
  FeatureflipError,
12
12
  InitializationError,
13
13
  )
14
+ from featureflip.inspector import EvaluationEvent, EvaluationInspector
14
15
  from featureflip.models import (
15
16
  Condition,
16
17
  ConditionGroup,
@@ -36,6 +37,8 @@ __all__: list[str] = [
36
37
  "ConfigurationError",
37
38
  "EvaluationContext",
38
39
  "EvaluationDetail",
40
+ "EvaluationEvent",
41
+ "EvaluationInspector",
39
42
  "EvaluationReason",
40
43
  "FeatureflipClient",
41
44
  "FeatureflipError",
@@ -28,8 +28,10 @@ from featureflip.context import EvaluationContext
28
28
  from featureflip.detail import EvaluationDetail, EvaluationReason
29
29
  from featureflip.evaluation import FlagEvaluator
30
30
  from featureflip.exceptions import InitializationError
31
+ from featureflip.inspector import EvaluationEvent
31
32
 
32
33
  if TYPE_CHECKING:
34
+ from featureflip.inspector import EvaluationInspector
33
35
  from featureflip.models import FlagConfiguration, Segment
34
36
 
35
37
  logger = structlog.get_logger()
@@ -37,6 +39,19 @@ logger = structlog.get_logger()
37
39
  T = TypeVar("T")
38
40
 
39
41
 
42
+ def _resolve_user_id(context: dict[str, Any]) -> str | None:
43
+ """Resolve the built-in user id from a raw context dict.
44
+
45
+ Honors the ``user_id`` / ``userId`` alias (universal across the engine and
46
+ every SDK) so a caller passing either spelling gets the same top-level
47
+ ``userId`` on analytics events. Returns ``None`` when neither is present.
48
+ """
49
+ raw = context.get("user_id")
50
+ if raw is None:
51
+ raw = context.get("userId")
52
+ return str(raw) if raw is not None else None
53
+
54
+
40
55
  class _SharedFeatureflipCore:
41
56
  """Internal shared core owning all expensive resources of a FeatureflipClient.
42
57
 
@@ -70,6 +85,13 @@ class _SharedFeatureflipCore:
70
85
  self._sdk_key = sdk_key
71
86
  self._config = config
72
87
 
88
+ # Filter to callables defensively — Python callers bypass the type
89
+ # hints, and a non-callable entry must be dropped at construction
90
+ # rather than blowing up on the evaluation hot path.
91
+ self._inspectors: list[EvaluationInspector] = [
92
+ inspector for inspector in (config.inspectors or []) if callable(inspector)
93
+ ]
94
+
73
95
  self._test_mode_values = test_mode_values
74
96
  self._test_values: dict[str, Any] = dict(test_mode_values) if test_mode_values else {}
75
97
 
@@ -127,12 +149,21 @@ class _SharedFeatureflipCore:
127
149
  return cls(sdk_key=None, config=Config(), test_mode_flags=flags)
128
150
 
129
151
  @classmethod
130
- def _create_for_testing_stub(cls, flags: dict[str, Any]) -> _SharedFeatureflipCore:
152
+ def _create_for_testing_stub(
153
+ cls,
154
+ flags: dict[str, Any],
155
+ inspectors: list[EvaluationInspector] | None = None,
156
+ ) -> _SharedFeatureflipCore:
131
157
  """Create a test-stub core that returns fixed values (no flag evaluation).
132
158
 
133
159
  This is the backing implementation for ``FeatureflipClient.for_testing``.
160
+ ``inspectors`` is optional so existing call sites are unaffected.
134
161
  """
135
- return cls(sdk_key=None, config=Config(), test_mode_values=flags)
162
+ return cls(
163
+ sdk_key=None,
164
+ config=Config(inspectors=list(inspectors) if inspectors else []),
165
+ test_mode_values=flags,
166
+ )
136
167
 
137
168
  # --- Refcount ---
138
169
 
@@ -185,7 +216,27 @@ class _SharedFeatureflipCore:
185
216
 
186
217
  In test-stub mode, returns the fixed value for the given key (or a
187
218
  FLAG_NOT_FOUND detail if the key isn't in the stub map).
219
+
220
+ This is the single evaluation choke point — every ``variation`` /
221
+ ``variation_detail`` call funnels through it — so the registered
222
+ evaluation inspectors are notified here, exactly once per call, with
223
+ the value and reason the caller actually receives. ``_evaluate``
224
+ resolves a complete detail on *every* exit path (test-stub hit,
225
+ test-stub miss, flag-not-found, evaluated result, ``None``-value
226
+ default substitution, and the error branch), so notifying once around
227
+ it instruments them all with no risk of a double fire.
188
228
  """
229
+ detail = self._evaluate(key, context, default)
230
+ self._notify_inspectors(key, context, detail)
231
+ return detail
232
+
233
+ def _evaluate(
234
+ self,
235
+ key: str,
236
+ context: dict[str, Any],
237
+ default: T,
238
+ ) -> EvaluationDetail:
239
+ """Resolve the evaluation detail. Never raises — see ``evaluate``."""
189
240
  try:
190
241
  # Test-stub mode: short-circuit to fixed values
191
242
  if self._test_mode_values is not None:
@@ -208,6 +259,21 @@ class _SharedFeatureflipCore:
208
259
  flag, eval_context, self._get_segment, all_flags=all_flags
209
260
  )
210
261
 
262
+ # Malformed config: the evaluator selected a variation key the flag
263
+ # does not define (e.g. a fallthrough/rule naming a since-deleted
264
+ # variation). Degrade to the caller's default and report ERROR,
265
+ # mirroring the engine's ServeVariation + the C#/Java SDKs (#1989).
266
+ # A variation that genuinely exists with a None value is NOT this
267
+ # case — hence the key lookup rather than a ``value is None`` check.
268
+ if detail.variation_key and flag.get_variation(detail.variation_key) is None:
269
+ return EvaluationDetail(
270
+ value=default,
271
+ reason=EvaluationReason.ERROR,
272
+ rule_id=detail.rule_id,
273
+ variation_key=detail.variation_key,
274
+ prerequisite_key=detail.prerequisite_key,
275
+ )
276
+
211
277
  if detail.value is None:
212
278
  return EvaluationDetail(
213
279
  value=default,
@@ -231,6 +297,57 @@ class _SharedFeatureflipCore:
231
297
  raise RuntimeError("set_test_value() can only be called on a test-stub core")
232
298
  self._test_values[key] = value
233
299
 
300
+ # --- Evaluation inspectors ---
301
+
302
+ def _notify_inspectors(
303
+ self,
304
+ flag_key: str,
305
+ context: dict[str, Any],
306
+ detail: EvaluationDetail,
307
+ ) -> None:
308
+ """Fire the registered evaluation inspectors for one evaluation.
309
+
310
+ Never raises. Both halves are isolated:
311
+
312
+ * Building the event must never disturb evaluation — a caller passing a
313
+ non-mapping ``context`` (or mutating its own dict concurrently) would
314
+ otherwise make ``dict(context)`` throw *out of* ``evaluate`` and
315
+ silently downgrade the caller to their default, purely because an
316
+ inspector happened to be registered. On failure the notification is
317
+ skipped and logged.
318
+ * A throwing inspector is isolated: it neither changes the value the
319
+ caller receives nor stops the remaining inspectors from firing; the
320
+ failure is logged and swallowed.
321
+
322
+ The event-build guard lives *here*, not around ``_evaluate`` in
323
+ ``evaluate`` — wrapping the call site would route the failure into the
324
+ evaluation error handling and could double-fire the inspectors.
325
+ """
326
+ # Hot-path guard: with no inspectors registered, allocate nothing.
327
+ if not self._inspectors:
328
+ return
329
+
330
+ try:
331
+ event = EvaluationEvent(
332
+ flag_key=flag_key,
333
+ # Copy so a buggy inspector can't mutate the caller's context.
334
+ context=dict(context or {}),
335
+ value=detail.value,
336
+ reason=detail.reason,
337
+ variation_key=detail.variation_key,
338
+ rule_id=detail.rule_id,
339
+ prerequisite_key=detail.prerequisite_key,
340
+ )
341
+ except Exception as e:
342
+ logger.warning("inspector_event_build_error", key=flag_key, error=str(e))
343
+ return
344
+
345
+ for inspector in self._inspectors:
346
+ try:
347
+ inspector(event)
348
+ except Exception as e:
349
+ logger.warning("inspector_error", key=flag_key, error=str(e))
350
+
234
351
  # --- Internal flag store ---
235
352
 
236
353
  def _get_flag(self, key: str) -> FlagConfiguration | None:
@@ -373,10 +490,13 @@ class _SharedFeatureflipCore:
373
490
  """Queue a custom event."""
374
491
  if self._test_mode_values is not None or self._event_processor is None:
375
492
  return
493
+ # Send the backend SdkEventDto shape: top-level userId, no `context`.
494
+ # See apps/evaluation-api Controllers/SdkController.RecordEvents — the
495
+ # backend has no context/value/reason field, so anything else is dropped.
376
496
  event = {
377
497
  "type": "Custom",
378
498
  "flagKey": event_name,
379
- "context": context or {},
499
+ "userId": _resolve_user_id(context or {}),
380
500
  "metadata": metadata or {},
381
501
  "timestamp": datetime.now(timezone.utc).isoformat(),
382
502
  }
@@ -386,10 +506,19 @@ class _SharedFeatureflipCore:
386
506
  """Send user attributes for segment building."""
387
507
  if self._test_mode_values is not None or self._event_processor is None:
388
508
  return
509
+ # Promote the user id to the top-level userId field and put the remaining
510
+ # attributes in metadata (stripping both alias spellings so the identity
511
+ # isn't duplicated inside the metadata bag).
512
+ attributes = {
513
+ k: v
514
+ for k, v in (context or {}).items()
515
+ if k not in ("user_id", "userId")
516
+ }
389
517
  event = {
390
518
  "type": "Identify",
391
519
  "flagKey": "$identify",
392
- "context": context or {},
520
+ "userId": _resolve_user_id(context or {}),
521
+ "metadata": attributes,
393
522
  "timestamp": datetime.now(timezone.utc).isoformat(),
394
523
  }
395
524
  self._event_processor.queue_event(event)
@@ -402,24 +531,25 @@ class _SharedFeatureflipCore:
402
531
  def queue_evaluation_event(
403
532
  self,
404
533
  key: str,
405
- value: Any,
406
534
  context: dict[str, Any],
407
- reason: EvaluationReason,
408
- rule_id: str | None = None,
535
+ variation_key: str | None,
409
536
  ) -> None:
410
- """Queue an evaluation event (called by the handle's variation() method)."""
537
+ """Queue an evaluation event (called by the handle's variation() method).
538
+
539
+ Emits the backend SdkEventDto shape — top-level ``userId`` and
540
+ ``variation`` — mirroring the JS SDK. The backend has no context/value/
541
+ reason/ruleId fields, so they are not sent (System.Text.Json would drop
542
+ them and bind UserId/Variation to null; see #1920).
543
+ """
411
544
  if self._event_processor is None:
412
545
  return
413
546
  event: dict[str, Any] = {
414
547
  "type": "Evaluation",
415
548
  "flagKey": key,
416
- "value": value,
417
- "context": context,
418
- "reason": reason.value,
549
+ "userId": _resolve_user_id(context or {}),
550
+ "variation": variation_key,
419
551
  "timestamp": datetime.now(timezone.utc).isoformat(),
420
552
  }
421
- if rule_id is not None:
422
- event["ruleId"] = rule_id
423
553
  self._event_processor.queue_event(event)
424
554
 
425
555
  # --- Shutdown ---
@@ -572,7 +702,12 @@ def _reset_for_testing() -> None:
572
702
 
573
703
 
574
704
  def _configs_equal(a: Config, b: Config) -> bool:
575
- """Compare two Config objects by value across all 9 fields."""
705
+ """Compare two Config objects by value across the 9 comparable fields.
706
+
707
+ ``inspectors`` is deliberately excluded: callbacks aren't structurally
708
+ comparable, and a differing callback must not trigger the "different
709
+ options" warning for a shared core.
710
+ """
576
711
  return (
577
712
  a.base_url == b.base_url
578
713
  and a.connect_timeout == b.connect_timeout
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from typing import Any, TypeVar, cast
6
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
7
7
 
8
8
  import structlog
9
9
 
@@ -11,6 +11,9 @@ from featureflip._core import _get_or_create_core, _SharedFeatureflipCore
11
11
  from featureflip.config import Config
12
12
  from featureflip.detail import EvaluationDetail, EvaluationReason
13
13
 
14
+ if TYPE_CHECKING:
15
+ from featureflip.inspector import EvaluationInspector
16
+
14
17
  logger = structlog.get_logger()
15
18
 
16
19
  T = TypeVar("T")
@@ -103,10 +106,8 @@ class FeatureflipClient:
103
106
  if track:
104
107
  self._core.queue_evaluation_event(
105
108
  key=key,
106
- value=value,
107
109
  context=context or {},
108
- reason=detail.reason,
109
- rule_id=detail.rule_id,
110
+ variation_key=detail.variation_key,
110
111
  )
111
112
  return cast("T", value)
112
113
  except Exception as e:
@@ -203,7 +204,11 @@ class FeatureflipClient:
203
204
  # --- Test-stub factory ---
204
205
 
205
206
  @classmethod
206
- def for_testing(cls, flags: dict[str, Any]) -> FeatureflipClient:
207
+ def for_testing(
208
+ cls,
209
+ flags: dict[str, Any],
210
+ inspectors: list[EvaluationInspector] | None = None,
211
+ ) -> FeatureflipClient:
207
212
  """Create a test client with fixed flag values.
208
213
 
209
214
  The test client:
@@ -215,6 +220,11 @@ class FeatureflipClient:
215
220
 
216
221
  Args:
217
222
  flags: Dictionary mapping flag keys to their values.
223
+ inspectors: Optional evaluation inspectors, so code under test that
224
+ relies on inspector side effects can be unit-tested against a
225
+ stub client. Omitted by default — every existing call site is
226
+ unaffected. Inspectors fire on both stub exit paths (a stub hit
227
+ and a stub miss).
218
228
 
219
229
  Returns:
220
230
  A test client instance.
@@ -226,14 +236,29 @@ class FeatureflipClient:
226
236
  ... })
227
237
  >>> client.variation("feature-a", {}, default=False)
228
238
  True
239
+
240
+ Example with an inspector:
241
+ >>> seen = []
242
+ >>> client = FeatureflipClient.for_testing(
243
+ ... {"feature-a": True}, inspectors=[seen.append]
244
+ ... )
245
+ >>> client.variation("feature-a", {}, default=False)
246
+ True
247
+ >>> seen[0].flag_key
248
+ 'feature-a'
229
249
  """
230
250
  # Create instance without calling __init__ (same technique the original
231
251
  # used). Then wire up a standalone test-stub core that never enters
232
252
  # _LIVE_CORES (it has no sdk_key).
253
+ #
254
+ # NOTE: this bypasses __init__ entirely, so any change to the client's
255
+ # constructor shape (new instance attribute, new core wiring) must be
256
+ # mirrored here or test clients silently drift from real ones — which
257
+ # is exactly how `inspectors` came to be dropped on this path.
233
258
  instance = object.__new__(cls)
234
259
  instance._core = _SharedFeatureflipCore(
235
260
  sdk_key=None,
236
- config=Config(),
261
+ config=Config(inspectors=list(inspectors) if inspectors else []),
237
262
  test_mode_values=dict(flags),
238
263
  )
239
264
  instance._closed = False
@@ -254,20 +279,15 @@ class FeatureflipClient:
254
279
  def _queue_evaluation_event(
255
280
  self,
256
281
  key: str,
257
- value: Any,
258
282
  context: dict[str, Any],
259
- reason: EvaluationReason,
260
- rule_id: str | None = None,
283
+ variation_key: str | None = None,
261
284
  ) -> None:
262
285
  """Queue an evaluation event. Delegates to the shared core.
263
286
 
264
- Preserved for backwards compatibility with existing tests that call
265
- this method directly on the client.
287
+ Preserved for tests that call this method directly on the client.
266
288
  """
267
289
  self._core.queue_evaluation_event(
268
290
  key=key,
269
- value=value,
270
291
  context=context,
271
- reason=reason,
272
- rule_id=rule_id,
292
+ variation_key=variation_key,
273
293
  )
@@ -2,7 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from featureflip.inspector import EvaluationInspector
6
10
 
7
11
 
8
12
  @dataclass
@@ -19,6 +23,13 @@ class Config:
19
23
  flush_interval: Interval in seconds for flushing event batches.
20
24
  flush_batch_size: Maximum number of events in a batch before flushing.
21
25
  init_timeout: Timeout in seconds for client initialization.
26
+ inspectors: Callables invoked synchronously with an ``EvaluationEvent``
27
+ on every flag evaluation. Void observers — the return value is
28
+ ignored, and a raising inspector cannot affect the evaluated value.
29
+ Deliberately excluded from the config-equality comparison (see
30
+ ``_core._configs_equal``): callbacks aren't structurally comparable
31
+ and a differing callback must not trigger the "different options"
32
+ warning.
22
33
  """
23
34
 
24
35
  base_url: str = "https://eval.featureflip.io"
@@ -30,6 +41,7 @@ class Config:
30
41
  flush_interval: float = 30.0
31
42
  flush_batch_size: int = 100
32
43
  init_timeout: float = 10.0
44
+ inspectors: list[EvaluationInspector] = field(default_factory=list)
33
45
 
34
46
  def __post_init__(self) -> None:
35
47
  """Validate and normalize configuration values."""
@@ -0,0 +1,72 @@
1
+ """Evaluation inspector types for the Featureflip SDK.
2
+
3
+ An *inspector* is a plain callable invoked synchronously, in-process, on every
4
+ flag evaluation. It receives an :class:`EvaluationEvent` describing what the
5
+ caller actually got back and returns nothing — inspectors are void observers,
6
+ meant for piping exposures into analytics / experimentation tooling with no
7
+ backend dependency.
8
+
9
+ Register inspectors at client construction::
10
+
11
+ from featureflip import Config, FeatureflipClient
12
+
13
+ client = FeatureflipClient(
14
+ sdk_key="sdk-xxx",
15
+ config=Config(inspectors=[lambda e: posthog.capture("$feature_flag_called", e)]),
16
+ )
17
+
18
+ Inspectors are honored on the *first* construction for a given SDK key, like
19
+ every other config option (the shared core is cached per key).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ if TYPE_CHECKING:
30
+ from featureflip.detail import EvaluationReason
31
+
32
+
33
+ def _utc_now_iso() -> str:
34
+ """Return the current UTC time as an ISO-8601 string."""
35
+ return datetime.now(timezone.utc).isoformat()
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EvaluationEvent:
40
+ """A single flag evaluation, handed to every registered inspector.
41
+
42
+ This payload is the frozen cross-SDK inspector contract; the field names
43
+ mirror the other Featureflip SDKs (adapted to Python's snake_case).
44
+
45
+ Attributes:
46
+ flag_key: The key of the flag that was evaluated.
47
+ context: A copy of the full evaluation context. Mutating it does not
48
+ affect the caller's dictionary. Treat as read-only.
49
+ value: The value the caller actually receives (default already applied).
50
+ reason: Why this value was served. Uses the Python SDK's native
51
+ ``EvaluationReason`` enum — each SDK emits its own reason casing.
52
+ variation_key: The winning variation key. ``None`` when the flag wasn't
53
+ found or evaluation errored.
54
+ rule_id: The matched rule's id — set only when the reason is
55
+ ``RULE_MATCH``.
56
+ prerequisite_key: The failing prerequisite's flag key — set only when
57
+ the reason is ``PREREQUISITE_FAILED``.
58
+ timestamp: ISO-8601 timestamp of the evaluation.
59
+ """
60
+
61
+ flag_key: str
62
+ context: dict[str, Any]
63
+ value: Any
64
+ reason: EvaluationReason
65
+ variation_key: str | None = None
66
+ rule_id: str | None = None
67
+ prerequisite_key: str | None = None
68
+ timestamp: str = field(default_factory=_utc_now_iso)
69
+
70
+
71
+ EvaluationInspector = Callable[["EvaluationEvent"], None]
72
+ """A void observer invoked once per evaluation with an :class:`EvaluationEvent`."""