fix(analytics): allow capability to offload reportExposure to async thread (SDK-80)#181
Conversation
…hread Exposure tracking on the sync path called the user's tracker (and therefore an HTTP POST) inline, blocking every getVariant / getVariantValue / isEnabled call by the full /track round trip. The async paths already use asyncio.create_task; only the sync path was paying the cost. Add an optional `exposure_executor: concurrent.futures.Executor` field to FlagsConfig. When set, the sync providers dispatch the tracker call via executor.submit so flag evaluation returns as soon as the local logic finishes. None (the default) preserves the existing inline behavior. Mirrors mixpanel-java#85. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #181 +/- ##
==========================================
+ Coverage 95.98% 96.09% +0.11%
==========================================
Files 13 13
Lines 2414 2560 +146
Branches 136 139 +3
==========================================
+ Hits 2317 2460 +143
- Misses 63 66 +3
Partials 34 34
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…rage - Add an "Async Exposure Tracking" section to the openfeature-provider README showing how to configure a ThreadPoolExecutor. - Add tests covering the manual track_exposure_event API path (previously only the implicit-via-get_variant path was covered). - Add explicit tests asserting the default (None) still runs inline on the calling thread. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Confidence Score: 5/5Safe to merge — the change is additive, the default is a no-op, and the previously flagged edge cases are all addressed. The executor path is opt-in with a None default, so existing callers are completely unaffected. The dispatch_exposure helper correctly handles the shut-down-executor RuntimeError, logs background-thread tracker failures via a done-callback, and guards future.exception() against CancelledError. Tests cover inline, off-thread, error-logging, and cancelled-future paths symmetrically across both providers. No files require special attention.
|
| Filename | Overview |
|---|---|
| mixpanel/flags/utils.py | New dispatch_exposure helper with executor submit, RuntimeError guard, and done-callback for exception logging; _log_tracker_future_exception correctly guards against CancelledError. |
| mixpanel/flags/types.py | Adds exposure_executor: Optional[Executor] = None to FlagsConfig with arbitrary_types_allowed=True already set; correct Pydantic pattern for non-serializable runtime objects. |
| mixpanel/flags/local_feature_flags.py | Sync paths now delegate to _dispatch_exposure -> dispatch_exposure; async paths unchanged. EXPOSURE_EVENT import correctly removed since it's now encapsulated in utils. |
| mixpanel/flags/remote_feature_flags.py | Same pattern as local provider; EXPOSURE_EVENT import retained because async methods still reference it directly. |
| mixpanel/flags/test_utils.py | New unit tests cover inline dispatch, error-logging on executor thread, and cancelled-future safety; all targeted and correct. |
| mixpanel/flags/test_local_feature_flags.py | Three new async tests verify default inline behavior and executor off-thread dispatch with real thread-name assertions. |
| mixpanel/flags/test_remote_feature_flags.py | Three new sync tests mirror the local provider tests; coverage is symmetric. |
| openfeature-provider/README.md | Accurate documentation of new executor option with correct usage example and note that async methods ignore exposure_executor. |
Sequence Diagram
sequenceDiagram
participant Caller
participant Provider as Local/RemoteProvider
participant dispatch as dispatch_exposure()
participant Inline as tracker (inline)
participant Executor as ThreadPoolExecutor
participant BG as Worker Thread
Caller->>Provider: get_variant_value()
Provider->>Provider: evaluate flag locally
Provider->>dispatch: _dispatch_exposure(distinct_id, props)
alt exposure_executor is None (default)
dispatch->>Inline: tracker(distinct_id, EXPOSURE_EVENT, props)
Inline-->>dispatch: return
dispatch-->>Provider: return
Provider-->>Caller: SelectedVariant (after tracker completes)
else exposure_executor set
dispatch->>Executor: executor.submit(tracker, ...)
dispatch->>dispatch: future.add_done_callback(_log_tracker_future_exception)
dispatch-->>Provider: return (immediately)
Provider-->>Caller: SelectedVariant (no wait)
Executor->>BG: tracker(distinct_id, EXPOSURE_EVENT, props)
BG-->>Executor: done / exception
Executor->>dispatch: _log_tracker_future_exception(future)
end
Reviews (5): Last reviewed commit: "fix(flags): guard _log_tracker_future_ex..." | Re-trigger Greptile
…ures executor.submit(...) returned a Future that was immediately discarded, so tracker exceptions raised on the executor thread disappeared with the Future. Added a done_callback that logs future.exception() at ERROR so failures are visible. Also collapsed the two identical _dispatch_exposure methods on LocalFeatureFlagsProvider and RemoteFeatureFlagsProvider into a shared utils.dispatch_exposure helper. Any future change to the dispatch policy (retry, timeout, tracing) now lives in one place. New test test_dispatch_exposure_logs_executor_thread_exceptions locks in the callback behavior — it would fail without add_done_callback.
|
Pushed P2 — silently swallowed tracker exceptions ( P2 — All 89 flag tests pass locally. |
Applies ruff format to test_utils.py and utils.py, and moves the annotation-only Executor/Future imports behind TYPE_CHECKING to satisfy TC003.
…-executor # Conflicts: # mixpanel/flags/types.py
…ures Greptile P1 on #181: future.exception() raises CancelledError on a cancelled future, and CancelledError is a BaseException (not Exception) in Python 3.8+. Future._invoke_callbacks catches only Exception, so a CancelledError from this done-callback would escape it and propagate into whatever triggered the cancellation — most commonly executor.shutdown(cancel_futures=True), crashing the shutdown call. Guards with future.cancelled() before touching future.exception(). Added test_log_tracker_future_exception_ignores_cancelled_future that cancels a fresh Future and invokes the callback directly, asserting it doesn't raise and doesn't emit an error log. Fails on the previous callback wiring. 106 flag tests pass; ruff check + format clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resolves conflicts with #181 (SDK-80 exposure_executor / dispatch_exposure) which landed on master after this branch was opened: - utils.py: union of imports (asyncio + asgiref from HEAD, logging + Executor/Future/Callable from master); both TYPE_CHECKING imports and the module-level logger kept. - local_feature_flags.py, remote_feature_flags.py: union of the .utils imports (both close_async_client_from_sync and dispatch_exposure). - test_utils.py: extended TestUtils with the dispatch_exposure / _log_tracker_future_exception tests from master, then appended the new TestCloseAsyncClientFromSync class from HEAD. 112 flag tests pass; ruff check + format clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Exposure tracking on the sync path called the user's tracker — and therefore an HTTP POST — inline, blocking every
get_variant/get_variant_value/is_enabledcall by the full/trackround trip. The async paths already useasyncio.create_task; only the sync paths were paying the cost.Add an optional
exposure_executor: concurrent.futures.Executor | None = Nonefield toFlagsConfig(inherited by bothLocalFlagsConfigandRemoteFlagsConfig). When set, the sync providers dispatch the tracker call viaexecutor.submit; flag evaluation returns as soon as the local logic finishes.None(the default) preserves the existing inline behavior — no breaking change for current users.Usage
Context
Linear: SDK-80. Mirrors mixpanel-java#85. Audit-driven; same fix being applied to mixpanel-ruby and mixpanel-go in parallel PRs.
Test plan
exposure_executor=Nonekeeps the old behavior unchanged)thread_name_prefix="exposure") and not the calling thread