Skip to content

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

Open
tylerjroach wants to merge 3 commits into
masterfrom
feat/async-exposure-executor
Open

fix(analytics): allow capability to offload reportExposure to async thread (SDK-80)#157
tylerjroach wants to merge 3 commits into
masterfrom
feat/async-exposure-executor

Conversation

@tylerjroach

@tylerjroach tylerjroach commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Exposure tracking called the tracker_callback — and therefore an HTTP POST — inline, blocking every get_variant / get_variant_value / is_enabled call by the full /track round trip.

Add an optional :exposure_executor config key on both LocalFlagsProvider and RemoteFlagsProvider. The executor is duck-typed — anything that responds to #post(&block) works:

  • Concurrent::ExecutorService from concurrent-ruby
  • A 5-line Thread.new wrapper for users who don't want the dependency

When set, the tracker call is dispatched on the executor so flag evaluation returns as soon as the local logic finishes. Defaults to nil — preserves the existing inline behavior, no breaking change for current users.

Usage

# With concurrent-ruby
require 'concurrent'
executor = Concurrent::FixedThreadPool.new(1)
tracker = Mixpanel::Tracker.new(token, error_handler,
  local_flags_config: { exposure_executor: executor })

# Without concurrent-ruby
class ThreadPerCall
  def post(&block) = Thread.new(&block)
end
executor = ThreadPerCall.new

Context

Linear: SDK-80. Mirrors mixpanel-java#85. Audit-driven; same fix being applied to mixpanel-python and mixpanel-go in parallel PRs.

Test plan

  • Full flags spec suite passes (70 examples, including the existing inline-exposure specs — :exposure_executor defaults to nil so the old behavior is unchanged)
  • New spec asserts the tracker runs on a thread other than the calling thread when :exposure_executor is configured

…hread

Exposure tracking called the tracker_callback (and therefore an HTTP
POST) inline, blocking every get_variant / get_variant_value /
is_enabled call by the full /track round trip.

Add an optional :exposure_executor config key on both local and
remote flags configs. The executor is duck-typed — anything that
responds to #post(&block) works (Concurrent::ExecutorService, or
a Thread.new wrapper). When set, the tracker call is dispatched
on the executor so flag evaluation returns as soon as the local
logic finishes. Defaults to nil — preserves 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 ketanmixpanel June 30, 2026 17:16
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.63%. Comparing base (e5edf71) to head (1ea39be).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
lib/mixpanel-ruby/flags/flags_provider.rb 76.92% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #157      +/-   ##
==========================================
- Coverage   96.64%   96.63%   -0.02%     
==========================================
  Files          14       14              
  Lines         656      683      +27     
==========================================
+ Hits          634      660      +26     
- Misses         22       23       +1     
Flag Coverage Δ
openfeature 100.00% <ø> (ø)

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.

…rage

- Add an "Async Exposure Tracking" section to the openfeature-provider
  README showing both the concurrent-ruby and Thread.new wrapper
  patterns.
- Add specs covering the local provider with executor (previously only
  the remote provider had coverage), the manual track_exposure_event
  path, and explicit "default is inline" sanity checks on both providers.

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, defaults to the existing inline behavior, and all error paths are explicitly guarded.

The executor path is opt-in and nil-by-default, so no existing caller is affected. The new StandardError rescue in invoke_tracker normalizes error handling consistently for both inline and async paths. Specs cover inline behavior, off-thread dispatch, and error-handler routing with bounded timeouts, so regressions would be caught.

No files require special attention.

Important Files Changed

Filename Overview
lib/mixpanel-ruby/flags/flags_provider.rb Core change: adds dispatch_exposure / invoke_tracker helpers; both StandardError and MixpanelError are now rescued in invoke_tracker (applies to inline path too, which is a deliberate behavior improvement). Logic is clean and nil-guards on @error_handler are consistent.
lib/mixpanel-ruby/flags/local_flags_provider.rb Adds exposure_executor: nil to DEFAULT_CONFIG and threads the value through provider_config to the parent class. Correct and minimal.
lib/mixpanel-ruby/flags/remote_flags_provider.rb Same pattern as local_flags_provider.rb — adds exposure_executor: nil to DEFAULT_CONFIG and passes it through. No issues.
spec/mixpanel-ruby/flags/local_flags_spec.rb Three new specs: inline-thread assertion, off-thread dispatch, and async error-handler routing. Timeout.timeout(2) prevents indefinite hangs.
spec/mixpanel-ruby/flags/remote_flags_spec.rb Two new specs covering inline and off-thread dispatch for RemoteFlagsProvider; same Timeout.timeout(2) guard pattern.
openfeature-provider/README.md Adds async exposure tracking section with concurrent-ruby and minimal ThreadPerCall examples; accurately describes defaults and both provider types.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant FlagsProvider
    participant ExposureExecutor
    participant InvokeTracker
    participant TrackerCallback

    Caller->>FlagsProvider: get_variant / get_variant_value / is_enabled
    FlagsProvider->>FlagsProvider: track_exposure_event(flag_key, variant, context)
    FlagsProvider->>FlagsProvider: dispatch_exposure(distinct_id, properties)

    alt exposure_executor is nil (default)
        FlagsProvider->>InvokeTracker: invoke_tracker(distinct_id, properties) [inline]
        InvokeTracker->>TrackerCallback: tracker_callback.call(...)
        TrackerCallback-->>InvokeTracker: return / raise
        InvokeTracker-->>FlagsProvider: rescue MixpanelError / StandardError → error_handler
        FlagsProvider-->>Caller: return variant (after HTTP round-trip)
    else exposure_executor configured
        FlagsProvider->>ExposureExecutor: "executor.post { invoke_tracker(...) }"
        FlagsProvider-->>Caller: return variant immediately
        ExposureExecutor->>InvokeTracker: invoke_tracker(distinct_id, properties) [off-thread]
        InvokeTracker->>TrackerCallback: tracker_callback.call(...)
        TrackerCallback-->>InvokeTracker: return / raise
        InvokeTracker-->>ExposureExecutor: rescue → error_handler (async)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant FlagsProvider
    participant ExposureExecutor
    participant InvokeTracker
    participant TrackerCallback

    Caller->>FlagsProvider: get_variant / get_variant_value / is_enabled
    FlagsProvider->>FlagsProvider: track_exposure_event(flag_key, variant, context)
    FlagsProvider->>FlagsProvider: dispatch_exposure(distinct_id, properties)

    alt exposure_executor is nil (default)
        FlagsProvider->>InvokeTracker: invoke_tracker(distinct_id, properties) [inline]
        InvokeTracker->>TrackerCallback: tracker_callback.call(...)
        TrackerCallback-->>InvokeTracker: return / raise
        InvokeTracker-->>FlagsProvider: rescue MixpanelError / StandardError → error_handler
        FlagsProvider-->>Caller: return variant (after HTTP round-trip)
    else exposure_executor configured
        FlagsProvider->>ExposureExecutor: "executor.post { invoke_tracker(...) }"
        FlagsProvider-->>Caller: return variant immediately
        ExposureExecutor->>InvokeTracker: invoke_tracker(distinct_id, properties) [off-thread]
        InvokeTracker->>TrackerCallback: tracker_callback.call(...)
        TrackerCallback-->>InvokeTracker: return / raise
        InvokeTracker-->>ExposureExecutor: rescue → error_handler (async)
    end
Loading

Reviews (2): Last reviewed commit: "fix(flags): report non-MixpanelError fro..." | Re-trigger Greptile

Comment thread spec/mixpanel-ruby/flags/local_flags_spec.rb Outdated
Comment thread spec/mixpanel-ruby/flags/remote_flags_spec.rb
Comment thread lib/mixpanel-ruby/flags/flags_provider.rb
…#pop

invoke_tracker only rescued MixpanelError. On the async path any other
exception (RuntimeError, NoMethodError, unwrapped network errors)
terminated the executor thread silently — the error_handler never saw
it. Added a StandardError branch that wraps into MixpanelError so
consumers see a consistent type.

Also bound the two async-executor specs with Timeout.timeout(2) around
tracker_ran.pop. If a future change makes the tracker block raise
before pushing :done, the spec now fails with a clear timeout instead
of hanging CI forever.

New spec 'reports non-MixpanelError exceptions from the async tracker'
locks in the widened rescue.
@tylerjroach

Copy link
Copy Markdown
Contributor Author

Pushed 1ea39be addressing all three Greptile threads.

P1 — blocking Queue#pop in both specs (local_flags_spec.rb:764, remote_flags_spec.rb:158): wrapped both tracker_ran.pop calls in Timeout.timeout(2) so a future change that makes the tracker block raise before pushing :done fails the spec with a clear timeout instead of hanging CI forever.

P2 — invoke_tracker only rescues MixpanelError (flags_provider.rb:132): added a rescue StandardError branch that wraps the underlying error into MixpanelError (preserving class + message) and dispatches to @error_handler. Otherwise RuntimeError / NoMethodError / unwrapped network errors raised on the executor thread would terminate it silently. New spec reports non-MixpanelError exceptions from the async tracker to error_handler covers this.

All 88 flag specs pass locally.

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.

1 participant