Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions sentry-rails/lib/sentry/rails/active_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def record(job, trace_headers: nil, user: nil, &block)
# CaptureExceptions, or a stale hub left by a recycled thread-pool
# thread) so the outer context continues working correctly after
# the job finishes.
original_hub = Thread.current.thread_variable_get(Sentry::THREAD_LOCAL)
original_hub = Sentry::HubStorage.get
Sentry.clone_hub_to_current_thread

Sentry.with_scope do |scope|
Expand Down Expand Up @@ -171,7 +171,7 @@ def record(job, trace_headers: nil, user: nil, &block)
end
end
ensure
Thread.current.thread_variable_set(Sentry::THREAD_LOCAL, original_hub)
Sentry::HubStorage.set(original_hub)
end

def set_messaging_data(transaction, job)
Expand Down
14 changes: 9 additions & 5 deletions sentry-ruby/lib/sentry-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require "sentry/span"
require "sentry/transaction"
require "sentry/hub"
require "sentry/hub_storage"
require "sentry/background_worker"
require "sentry/threaded_periodic_worker"
require "sentry/session_flusher"
Expand Down Expand Up @@ -271,7 +272,8 @@ def init(&block)
client = Client.new(config)
scope = Scope.new(max_breadcrumbs: config.max_breadcrumbs)
hub = Hub.new(client, scope)
Thread.current.thread_variable_set(THREAD_LOCAL, hub)
HubStorage.isolation_level = config.isolation_level
HubStorage.set(hub)
@main_hub = hub
@background_worker = Sentry::BackgroundWorker.new(config)
@session_flusher = config.session_tracking? ? Sentry::SessionFlusher.new(config, client) : nil
Expand Down Expand Up @@ -309,7 +311,7 @@ def close

MUTEX.synchronize do
@main_hub = nil
Thread.current.thread_variable_set(THREAD_LOCAL, nil)
HubStorage.clear
end
end

Expand Down Expand Up @@ -362,7 +364,7 @@ def get_current_hub
# ideally, we should do this proactively whenever a new thread is created
# but it's impossible for the SDK to keep track every new thread
# so we need to use this rather passive way to make sure the app doesn't crash
Thread.current.thread_variable_get(THREAD_LOCAL) || clone_hub_to_current_thread
HubStorage.get || clone_hub_to_current_thread
end

# Returns the current active client.
Expand All @@ -380,12 +382,14 @@ def get_current_scope
get_current_hub.current_scope
end

# Clones the main thread's active hub and stores it to the current thread.
# Clones the main hub and stores it for the current execution context
# (the current thread, or the current fiber when +config.isolation_level+
# is +:fiber+).
#
# @return [void]
def clone_hub_to_current_thread
return unless initialized?
Thread.current.thread_variable_set(THREAD_LOCAL, get_main_hub.clone)
HubStorage.set(get_main_hub.clone)
end

# Takes a block and yields the current active scope.
Expand Down
34 changes: 34 additions & 0 deletions sentry-ruby/lib/sentry/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,20 @@ class Configuration
# @return [Boolean]
attr_accessor :strict_trace_continuation

# Which execution primitive owns the SDK's current hub.
#
# [+:thread+ (default)] Store the hub in thread-local storage. Correct for
# thread-based servers (Puma, Unicorn) and background processors (Sidekiq,
# Resque). Every fiber on a thread shares one hub.
# [+:fiber+] Store the hub in Fiber Storage (Ruby 3.2+). 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. Requested on a Ruby without Fiber
# Storage (< 3.2), the SDK logs a warning and falls back to +:thread+.
#
# @return [Symbol]
attr_reader :isolation_level

# these are not config options
# @!visibility private
attr_reader :errors, :gem_specs
Expand Down Expand Up @@ -541,6 +555,10 @@ def initialize
self.capture_queue_time = true
self.org_id = nil
self.strict_trace_continuation = false
# Assign the ivar directly rather than through the setter: the default must
# not sync HubStorage (a throwaway Configuration.new would otherwise clobber
# the active isolation level). init and the setter handle syncing.
@isolation_level = :thread

spotlight_env = ENV["SENTRY_SPOTLIGHT"]
spotlight_bool = Sentry::Utils::EnvHelper.env_to_bool(spotlight_env, strict: true)
Expand Down Expand Up @@ -610,6 +628,22 @@ def release=(value)
@release = value
end

def isolation_level=(level)
level = level.to_sym if level.respond_to?(:to_sym)
applied = Sentry::HubStorage.normalize_isolation_level(level)

if level == :fiber && applied != :fiber
log_warn("isolation_level :fiber requires Ruby 3.2+ Fiber Storage; falling back to :thread on Ruby #{RUBY_VERSION}.")
end

@isolation_level = applied

# Keep the active hub storage in sync when the level is changed after the
# SDK is initialized. During `Sentry.init` the config block runs before the
# SDK is initialized, so init applies the level to HubStorage itself.
Sentry::HubStorage.isolation_level = applied if Sentry.initialized?
end
Comment thread
cursor[bot] marked this conversation as resolved.

def breadcrumbs_logger=(logger)
loggers =
if logger.is_a?(Array)
Expand Down
92 changes: 92 additions & 0 deletions sentry-ruby/lib/sentry/hub_storage.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# frozen_string_literal: true

module Sentry
# Stores the SDK's current {Hub} in an execution-context-local slot.
#
# The isolation level decides which execution primitive owns the hub:
#
# [+:thread+ (default)] The hub lives in thread-local storage
# (+Thread#thread_variable_get+/+thread_variable_set+). This is the SDK's
# historical behaviour and is correct for thread-based servers (Puma,
# Unicorn) and background processors (Sidekiq, Resque). Every fiber running
# on a thread shares one hub.
#
# [+:fiber+] The hub lives in Fiber Storage (+Fiber#[]+, Ruby 3.2+). Each
# fiber gets its own hub, and a newly created fiber inherits a copy of its
# parent's storage, so context still propagates into child fibers (for
# example graphql-ruby resolvers or +Async+ tasks) instead of being lost.
# This is the correct level for fiber-based servers such as Falcon, where
# many concurrent requests run as sibling fibers on a single thread and must
# not share a hub.
#
# Both levels use the same storage key (+Sentry::THREAD_LOCAL+, value
# +:sentry_hub+), kept stable for backwards compatibility because integrations
# and user code reference it directly.
#
# @api private
module HubStorage
# Isolation levels the SDK understands.
LEVELS = %i[thread fiber].freeze

class << self
# @return [Symbol] the currently active isolation level.
attr_reader :isolation_level

# @param level [Symbol] +:thread+ or +:fiber+.
# @raise [ArgumentError] if +level+ is not a known isolation level.
# @return [Symbol] the level that was applied.
def isolation_level=(level)
@isolation_level = normalize_isolation_level(level)
end

# Validates a requested isolation level and downgrades +:fiber+ to
# +:thread+ when Fiber Storage is unavailable (Ruby < 3.2), so the storage
# boundary can never be asked to call +Fiber[]+ on a Ruby that lacks it.
#
# @param level [Symbol]
# @raise [ArgumentError] if +level+ is not a known isolation level.
# @return [Symbol] the level that is safe to apply.
def normalize_isolation_level(level)
unless LEVELS.include?(level)
raise ArgumentError, "isolation_level must be one of #{LEVELS.inspect}, got #{level.inspect}"
end

level == :fiber && !fiber_storage_available? ? :thread : level
end

# @return [Hub, nil] the hub stored for the current execution context.
def get
if @isolation_level == :fiber
::Fiber[THREAD_LOCAL]
else
::Thread.current.thread_variable_get(THREAD_LOCAL)
end
end

# @param hub [Hub, nil]
# @return [Hub, nil]
def set(hub)
if @isolation_level == :fiber
::Fiber[THREAD_LOCAL] = hub
else
::Thread.current.thread_variable_set(THREAD_LOCAL, hub)
end
end

# Clears the hub for the current execution context.
# @return [void]
def clear
set(nil)
end

# Whether the running Ruby exposes Fiber Storage (+Fiber#[]+, Ruby 3.2+),
# the primitive the +:fiber+ isolation level is built on.
# @return [Boolean]
def fiber_storage_available?
::Fiber.respond_to?(:[]) && ::Fiber.respond_to?(:[]=)
end
end

self.isolation_level = :thread
end
end
10 changes: 7 additions & 3 deletions sentry-ruby/lib/sentry/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ def setup_sentry_test(&block)
test_client = Sentry::Client.new(dummy_config.dup)
main_hub.bind_client(test_client)

# Realign the current thread's hub with the main hub so direct
# Realign the current execution context's hub with the main hub so direct
# `sentry_events` reads and any hub the Rack middleware clones from the
# main hub all observe the same DummyTransport.
Thread.current.thread_variable_set(Sentry::THREAD_LOCAL, main_hub)
Sentry::HubStorage.set(main_hub)
end

# Clears all stored events and envelopes.
Expand Down Expand Up @@ -165,7 +165,11 @@ def reset_sentry_globals!
Sentry.instance_variable_set(:"@#{var}", nil)
end

Thread.current.thread_variable_set(Sentry::THREAD_LOCAL, nil)
# Clear the hub using the level that is still in effect, then restore
# the default so a `:fiber` test cannot leak its isolation level into
# subsequent tests that expect the default.
Sentry::HubStorage.clear
Sentry::HubStorage.isolation_level = :thread
end
end
end
Expand Down
45 changes: 45 additions & 0 deletions sentry-ruby/spec/sentry/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,51 @@ class SentryConfigurationSample < Sentry::Configuration
end
end

describe "#isolation_level" do
it "defaults to :thread" do
expect(subject.isolation_level).to eq(:thread)
end

it "accepts :thread and :fiber" do
# stub availability so the :fiber assertion is deterministic on Rubies < 3.2
allow(Sentry::HubStorage).to receive(:fiber_storage_available?).and_return(true)

subject.isolation_level = :fiber
expect(subject.isolation_level).to eq(:fiber)

subject.isolation_level = :thread
expect(subject.isolation_level).to eq(:thread)
end

it "coerces string values" do
allow(Sentry::HubStorage).to receive(:fiber_storage_available?).and_return(true)
subject.isolation_level = "fiber"
expect(subject.isolation_level).to eq(:fiber)
end
Comment thread
cursor[bot] marked this conversation as resolved.

it "raises ArgumentError for an unknown level" do
expect { subject.isolation_level = :process }
.to raise_error(ArgumentError, /isolation_level must be one of/)
end

context "when Fiber storage is available" do
it "keeps :fiber" do
allow(Sentry::HubStorage).to receive(:fiber_storage_available?).and_return(true)
subject.isolation_level = :fiber
expect(subject.isolation_level).to eq(:fiber)
end
end

context "when Fiber storage is unavailable" do
it "falls back to :thread with a warning" do
allow(Sentry::HubStorage).to receive(:fiber_storage_available?).and_return(false)
expect(subject).to receive(:log_warn).with(/requires Ruby 3\.2\+ Fiber Storage/)
subject.isolation_level = :fiber
expect(subject.isolation_level).to eq(:thread)
end
end
end

describe "#validate" do
it "logs a warning if StackProf is not installed" do
allow(Sentry).to receive(:dependency_installed?).with(:StackProf).and_return(false)
Expand Down
Loading