Skip to content

feat: add config.isolation_level for fiber-safe hub storage (Falcon/async)#3018

Open
SeanLF wants to merge 3 commits into
getsentry:masterfrom
SeanLF:feat/fiber-hub-storage
Open

feat: add config.isolation_level for fiber-safe hub storage (Falcon/async)#3018
SeanLF wants to merge 3 commits into
getsentry:masterfrom
SeanLF:feat/fiber-hub-storage

Conversation

@SeanLF

@SeanLF SeanLF commented Jul 11, 2026

Copy link
Copy Markdown

What

Adds an opt-in config.isolation_level = :thread | :fiber (default :thread) that controls where the SDK stores the current Hub.

  • :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_scope mutates the same scope stack, and scope/breadcrumbs/user leak between requests.

Reproduced against a real Async::HTTP::Server + the real Sentry::Rack::CaptureExceptions middleware, firing concurrent HTTP requests that each set a user, yield mid-scope, then capture an event:

[:thread] 6 error events | 5 attributed to the WRONG user
[:fiber]  6 error events | 0 attributed to the WRONG user

Under thread-local, 5 of 6 events were reported under the wrong user (cross-user PII bleed). :fiber fixes 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-javascript AsyncLocalStorage — both isolate per task and inherit into children), and it follows opentelemetry-ruby's 2025 move off thread-local storage (#1807).

Design

  • New Sentry::HubStorage owns get/set/clear of the current hub and switches on the isolation level. THREAD_LOCAL = :sentry_hub is kept as the storage key (user monkey-patches reference the constant).
  • config.isolation_level is applied at Sentry.init. Requesting :fiber on Ruby < 3.2 logs a warning and falls back to :thread (feature-detected, so nothing breaks on 2.7–3.1).
  • Every former thread_variable_* site now routes through HubStorage (core sentry-ruby.rb, plus sentry-rails active_job.rb and core test_helper.rb, which touched THREAD_LOCAL directly and would be wrong under :fiber).

Mirrors Rails' config.active_support.isolation_level ergonomics; uses Fiber[] (not Rails' non-inheriting Fiber.attr_accessor) because the SDK needs the inheritance property.

Sentry.init do |config|
  config.dsn = "..."
  config.isolation_level = :fiber # opt in on Falcon / async
end

Compatibility

  • Default :thread is byte-for-byte the previous behaviour. Puma/Sidekiq/Unicorn/Resque are unaffected.
  • Ruby >= 2.7 still supported; :fiber requires 3.2+ and degrades to :thread with a warning below that.
  • :fiber is 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 raw Thread.new spawned mid-request inherits the request's hub object (documented Ruby fiber-storage thread inheritance). Call Sentry.clone_hub_to_current_thread in the spawned thread to isolate it — same as today.

Tests

Performance

benchmark-ips (Ruby 4.0.5): Fiber[] get is 1.24x faster than thread_variable_get; at the level of a full Sentry.add_breadcrumb the two isolation levels are within measurement error. Not a hot-path regression.

Notes for reviewers

  • Two commits: core feature, then a 2-line sentry-rails follow-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.
  • No CHANGELOG.md edit (per the repo's auto-generated-at-release convention). Suggested entry:
    • Add config.isolation_level = :thread | :fiber for fiber-safe hub storage on fiber-based servers (Falcon/async), fixing cross-request scope contamination. Default :thread keeps existing behaviour.

Refs #1495. /cc @sl0thentr0py @solnic

SeanLF and others added 2 commits July 11, 2026 09:41
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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

Comment thread sentry-ruby/lib/sentry/configuration.rb
Comment thread sentry-ruby/spec/sentry/configuration_spec.rb
…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>
@SeanLF

SeanLF commented Jul 11, 2026

Copy link
Copy Markdown
Author

Both findings addressed in 3ce928c:

  1. Post-init isolation sync (medium): Configuration#isolation_level= now applies the level to HubStorage when the SDK is already initialized, so a post-init change to Sentry.configuration.isolation_level actually takes effect instead of leaving the SDK on the old backend. The initialize default is assigned to the ivar directly so a throwaway Configuration.new can't clobber the active isolation level (only Sentry.init builds the live config).

  2. Fiber specs vs Ruby < 3.2 fallback (high): the :fiber-asserting specs now stub fiber_storage_available? so they're deterministic across the Ruby matrix, plus an explicit downgrade-to-:thread spec. They previously asserted :fiber unconditionally and would have failed on the < 3.2 cells, where :fiber correctly falls back to :thread. Added a regression test for the post-init sync too.

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