feat: add config.isolation_level for fiber-safe hub storage (Falcon/async)#3018
feat: add config.isolation_level for fiber-safe hub storage (Falcon/async)#3018SeanLF wants to merge 3 commits into
Conversation
The current hub is stored in thread-local storage. On a fiber-based server (Falcon/async) many concurrent requests run as sibling fibers on one thread, so they share a single hub. When a request holds a scope across a reactor yield (streaming, IO), a concurrent request's with_scope mutates the same scope stack and scope/transaction/breadcrumbs/user leak between requests -- reproduced as 5/6 events attributed to the wrong user, including their email (cross-user PII bleed). Add an opt-in config.isolation_level (:thread default, :fiber) that routes the current hub through Sentry::HubStorage. :fiber uses Ruby 3.2+ Fiber Storage (Fiber[]), which gives per-request isolation AND child-fiber inheritance, so it fixes Falcon without re-introducing the graphql-ruby context loss that getsentry#1374/getsentry#1380 addressed (old fiber-local vars did not inherit; Fiber Storage does). Requesting :fiber on Ruby < 3.2 warns and falls back to :thread. Default :thread is byte-for-byte the previous behaviour. Mirrors Rails' config.active_support.isolation_level. Refs getsentry#1495. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ActiveJob integration saved and restored the surrounding hub with Thread.current.thread_variable_get/set(Sentry::THREAD_LOCAL) directly, which is wrong under config.isolation_level = :fiber (it would read/write thread storage while the active hub lives in fiber storage). Route both sites through Sentry::HubStorage so the save/restore follows the configured isolation level. Behaviour is byte-for-byte unchanged under the default :thread level. Verified with a real ActiveJob perform_now: under :thread the outer hub is restored and the job's user does not leak; under :fiber, concurrent jobs run as sibling fibers each keep their own user. Refs getsentry#1495. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5f89c7f. Configure here.
…pecs Two follow-ups from review: - Configuration#isolation_level= now applies the level to HubStorage when the SDK is already initialized, so changing Sentry.configuration.isolation_level after Sentry.init takes effect instead of leaving the SDK on the old backend. The default is assigned to the ivar directly in initialize so a throwaway Configuration.new cannot clobber the active isolation level (only Sentry.init builds the live config). - Make the :fiber isolation_level specs deterministic across Ruby versions by stubbing fiber_storage_available?, and add an explicit downgrade-to-:thread spec. Previously these asserted :fiber unconditionally and would fail on the Ruby < 3.2 CI cells, where :fiber correctly falls back to :thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Both findings addressed in 3ce928c:
|

What
Adds an opt-in
config.isolation_level = :thread | :fiber(default:thread) that controls where the SDK stores the currentHub.:thread(default) — thread-local storage, exactly today's behaviour. Nothing changes for anyone who doesn't opt in.:fiber— Ruby 3.2+ Fiber Storage (Fiber[]). Each fiber gets its own hub, and child fibers inherit it, so concurrent requests on a fiber-based server (Falcon,async) are isolated instead of sharing and corrupting one another's scope.This is the fiber-based option discussed and deferred in #1495.
Why
On a fiber server, concurrent requests run as sibling fibers on one thread, so they share the single thread-local hub. When a request holds a scope across a reactor yield (streaming, IO), a concurrent request's
with_scopemutates the same scope stack, and scope/breadcrumbs/user leak between requests.Reproduced against a real
Async::HTTP::Server+ the realSentry::Rack::CaptureExceptionsmiddleware, firing concurrent HTTP requests that each set a user, yield mid-scope, then capture an event:Under thread-local, 5 of 6 events were reported under the wrong user (cross-user PII bleed).
:fiberfixes it.Why this is safe now, when #1380 moved the other way
#1380 (2021) moved off Ruby's old fiber-local variables (
Thread.current[]) because they don't inherit into child fibers, which lost graphql-ruby's context (#1374). Ruby 3.2 added Fiber Storage (Fiber#storage) — a different primitive that copies the parent's storage into a newly created fiber. It gives per-request isolation and child-fiber inheritance at once, so it fixes fiber servers without re-breaking #1374. The added regression tests prove both hold.This is also how the other SDKs already work (sentry-python
contextvars, sentry-javascriptAsyncLocalStorage— both isolate per task and inherit into children), and it follows opentelemetry-ruby's 2025 move off thread-local storage (#1807).Design
Sentry::HubStorageowns get/set/clear of the current hub and switches on the isolation level.THREAD_LOCAL = :sentry_hubis kept as the storage key (user monkey-patches reference the constant).config.isolation_levelis applied atSentry.init. Requesting:fiberon Ruby < 3.2 logs a warning and falls back to:thread(feature-detected, so nothing breaks on 2.7–3.1).thread_variable_*site now routes throughHubStorage(coresentry-ruby.rb, plussentry-railsactive_job.rband coretest_helper.rb, which touchedTHREAD_LOCALdirectly and would be wrong under:fiber).Mirrors Rails'
config.active_support.isolation_levelergonomics; usesFiber[](not Rails' non-inheritingFiber.attr_accessor) because the SDK needs the inheritance property.Compatibility
:threadis byte-for-byte the previous behaviour. Puma/Sidekiq/Unicorn/Resque are unaffected.>= 2.7still supported;:fiberrequires 3.2+ and degrades to:threadwith a warning below that.:fiberis safe on threaded servers too (each request re-clones the hub); the difference only matters when child fibers are spawned per request.Caveat (documented)
Under
:fiber, a rawThread.newspawned mid-request inherits the request's hub object (documented Ruby fiber-storage thread inheritance). CallSentry.clone_hub_to_current_threadin the spawned thread to isolate it — same as today.Tests
spec/sentry/hub_storage_spec.rb,config.isolation_levelspecs, and a fiber-isolation regression inspec/sentry_spec.rb(sibling fibers keep isolated user attribution across a yield; child fiber inherits the parent hub).:fiberspecs guard on Fiber-storage availability.Performance
benchmark-ips (Ruby 4.0.5):
Fiber[]get is 1.24x faster thanthread_variable_get; at the level of a fullSentry.add_breadcrumbthe two isolation levels are within measurement error. Not a hot-path regression.Notes for reviewers
sentry-railsfollow-on so ActiveJob is correct under:fiber. Happy to split the rails commit into a separate PR if you'd prefer to keep this core-only.CHANGELOG.mdedit (per the repo's auto-generated-at-release convention). Suggested entry:Refs #1495. /cc @sl0thentr0py @solnic