Skip to content

fix(analytics): allow capability to offload reportExposure to async thread (SDK-80)#181

Merged
tylerjroach merged 7 commits into
masterfrom
feat/async-exposure-executor
Jul 23, 2026
Merged

fix(analytics): allow capability to offload reportExposure to async thread (SDK-80)#181
tylerjroach merged 7 commits into
masterfrom
feat/async-exposure-executor

Conversation

@tylerjroach

@tylerjroach tylerjroach commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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_enabled call by the full /track round trip. The async paths already use asyncio.create_task; only the sync paths were paying the cost.

Add an optional exposure_executor: concurrent.futures.Executor | None = None field to FlagsConfig (inherited by both LocalFlagsConfig and RemoteFlagsConfig). When set, the sync providers dispatch the tracker call via executor.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

from concurrent.futures import ThreadPoolExecutor
from mixpanel.flags.types import LocalFlagsConfig

executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="mixpanel-exposure")
config = LocalFlagsConfig(exposure_executor=executor)

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

  • Full flags suite passes (83 tests, including the existing inline-exposure tests — exposure_executor=None keeps the old behavior unchanged)
  • New test on both sync local and sync remote providers asserts the tracker runs on the executor's thread (thread_name_prefix="exposure") and not the calling thread

…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>
@tylerjroach
tylerjroach requested review from a team and jakewski June 30, 2026 17:07
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.09%. Comparing base (fa23251) to head (31de9b3).

Files with missing lines Patch % Lines
mixpanel/flags/utils.py 84.21% 3 Missing ⚠️
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              
Flag Coverage Δ
openfeature-provider 54.34% <32.14%> (-0.38%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tylerjroach and others added 2 commits June 30, 2026 14:11
…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>
@tylerjroach tylerjroach changed the title fix(analytics): allow capability to offload reportExposure to async thread fix(analytics): allow capability to offload reportExposure to async thread (SDK-80) Jun 30, 2026
@linear-code

linear-code Bot commented Jun 30, 2026

Copy link
Copy Markdown

SDK-80

@tylerjroach

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jul 2, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

Safe 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.

Important Files Changed

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
Loading

Reviews (5): Last reviewed commit: "fix(flags): guard _log_tracker_future_ex..." | Re-trigger Greptile

Comment thread mixpanel/flags/local_feature_flags.py Outdated
Comment thread mixpanel/flags/remote_feature_flags.py Outdated
…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.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed 34975e5 addressing both Greptile threads.

P2 — silently swallowed tracker exceptions (local_feature_flags.py:529): the new shared helper attaches a add_done_callback that pulls future.exception() and logs it at ERROR (Exposure event failed on executor thread: <type>: <msg>). Otherwise the Future retained the exception and it disappeared silently. New spec test_dispatch_exposure_logs_executor_thread_exceptions locks this in — it fails without the callback.

P2 — _dispatch_exposure duplicated verbatim (remote_feature_flags.py:285): extracted into mixpanel.flags.utils.dispatch_exposure(tracker, executor, distinct_id, properties). Both providers now delegate. Any future change to dispatch policy (retries, tracing, timeouts) lives in one place.

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.
ketanmixpanel
ketanmixpanel previously approved these changes Jul 21, 2026
…-executor

# Conflicts:
#	mixpanel/flags/types.py
Comment thread mixpanel/flags/utils.py
ketanmixpanel
ketanmixpanel previously approved these changes Jul 22, 2026
…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>
@tylerjroach
tylerjroach merged commit 74d73f2 into master Jul 23, 2026
37 checks passed
tylerjroach added a commit that referenced this pull request Jul 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants