Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d979c2b
Add DI.exception_backtrace C extension to avoid customer code dispatch
p-ddsign Mar 27, 2026
59efad8
Fix exception_backtrace to convert Thread::Backtrace to Array<String>
p-ddsign Mar 27, 2026
9b777e4
Fix StandardRB: remove redundant begin blocks
p-ddsign Mar 27, 2026
5b5eb0b
Add set_backtrace test and fix formatting in specs
p-ddsign Mar 27, 2026
9801c99
Fix undefined symbol: use UnboundMethod instead of internal Ruby func…
p-ddsign Mar 27, 2026
a1c75f4
Fix undefined symbol: use have_func to gate rb_backtrace_p
p-ddsign Mar 27, 2026
4f8e503
Replace C exception_backtrace with Ruby UnboundMethod + backtrace_loc…
p-ddsign Mar 27, 2026
02037d2
Fix RBS signature: exception_backtrace returns Location not String
p-ddsign Mar 27, 2026
95541ba
Inline exception_backtrace: use constant directly at call site
p-ddsign Mar 27, 2026
c98fb09
Fix Steep: update RBS for format_backtrace and remove BACKTRACE_FRAME…
p-ddsign Mar 27, 2026
59fe0b5
Merge branch 'master' into di-c-ext-exception-backtrace
p-datadog Mar 30, 2026
74c2f91
Fall back to string backtrace when backtrace_locations is nil
p-ddsign Mar 31, 2026
fbce545
Fix EXCEPTION_BACKTRACE test: UnboundMethod does not bypass overrides
p-ddsign Mar 31, 2026
e8b3e24
Explain why UnboundMethod doesn't bypass backtrace overrides
p-ddsign Mar 31, 2026
c97b952
Add doc explaining Exception backtrace internals and UnboundMethod be…
p-ddsign Mar 31, 2026
0d85d49
Remove exception backtrace doc (moved to claude-projects)
p-ddsign Mar 31, 2026
38a3537
Document all backtrace override scenarios in code comments
p-ddsign Mar 31, 2026
e574247
Merge branch 'master' into di-c-ext-exception-backtrace
p-datadog Mar 31, 2026
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
65 changes: 65 additions & 0 deletions lib/datadog/di.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,71 @@ module Datadog
module DI
INSTRUMENTED_COUNTERS_LOCK = Mutex.new

# Captured at load time from Exception itself (not a subclass).
# Used to bypass subclass overrides of backtrace_locations.
#
# This does NOT protect against monkeypatching Exception#backtrace_locations
# before dd-trace-rb loads — in that case we'd capture the monkeypatch.
# The practical threat model is customer subclasses overriding the method:
#
# class MyError < StandardError
# def backtrace_locations; []; end
# end
#
# The UnboundMethod bypasses subclass overrides: bind(exception).call
# always dispatches to the original Exception implementation.
#
# Note: if the subclass overrides #backtrace (not #backtrace_locations),
# MRI's setup_exception skips storing the VM backtrace entirely — both
# @bt and @bt_locations stay nil. In that case this UnboundMethod also
# returns nil. See EXCEPTION_BACKTRACE comment and
# docs/ruby/exception-backtrace-internals.md in claude-projects for the
# full MRI analysis.
EXCEPTION_BACKTRACE_LOCATIONS = Exception.instance_method(:backtrace_locations)

# Same UnboundMethod trick for Exception#backtrace (Array<String>).
# Used as a fallback when backtrace_locations returns nil — which happens
# when someone calls Exception#set_backtrace with an Array<String>.
#
# set_backtrace accepts Array<String> or nil. When called with strings,
# it replaces the VM-level backtrace: backtrace returns the new strings,
# but backtrace_locations returns nil because the VM cannot reconstruct
# Location objects from formatted strings. This occurs in exception
# wrapping patterns where a library catches an exception, creates a new
# one, and copies the original's string backtrace onto it via
# set_backtrace before re-raising.
#
# Ruby 3.4+ also allows set_backtrace(Array<Location>), which preserves
# backtrace_locations — but older Rubies and most existing code use
# the string form.
#
# LIMITATION: Unlike EXCEPTION_BACKTRACE_LOCATIONS, this UnboundMethod
# does NOT bypass subclass overrides of #backtrace. When a subclass
# overrides #backtrace, MRI's setup_exception (eval.c) calls the
# override via rb_get_backtrace, gets a non-nil result, and skips
# storing the real VM backtrace in @bt and @bt_locations entirely.
# The C function exc_backtrace then reads @bt (still nil from
# exc_init) and returns nil.
#
# By contrast, setup_exception only checks for #backtrace overrides,
# not #backtrace_locations overrides. So when only backtrace_locations
# is overridden, the real backtrace IS stored, and the UnboundMethod
# for backtrace_locations reads it directly from @bt_locations.
#
# This limitation is acceptable because this constant is only used as
# a fallback when backtrace_locations returns nil. In the common
# set_backtrace-with-strings case, no subclass override is involved
# and the fallback works. If a subclass does override #backtrace AND
# set_backtrace was called, set_backtrace writes to @bt via C
# regardless of overrides, so the fallback still works.
#
# The only unrecoverable case: a subclass overrides #backtrace, the
# exception is raised normally, and set_backtrace is never called.
# Both @bt and @bt_locations are nil — the real backtrace was never
# stored by raise. DI reports an empty stacktrace (type and message
# are still reported).
EXCEPTION_BACKTRACE = Exception.instance_method(:backtrace)

class << self
def enabled?
Datadog.configuration.dynamic_instrumentation.enabled
Expand Down
55 changes: 48 additions & 7 deletions lib/datadog/di/probe_notification_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,6 @@ def build_executed(context)
NANOSECONDS = 1_000_000_000
MILLISECONDS = 1000

# Matches Ruby backtrace frame format: "/path/file.rb:42:in `method_name'"
# Captures: $1 = file path, $2 = line number, $3 = method name
BACKTRACE_FRAME_PATTERN = /\A(.+):(\d+):in\s+[`'](.+)'\z/

def build_snapshot(context)
probe = context.probe

Expand Down Expand Up @@ -193,21 +189,66 @@ def serialize_throwable(exception)
# The exception class is already reported via the :type field.
'<REDACTED: not a string value>'
end
# Prefer backtrace_locations (structured Location objects) over
# backtrace (formatted strings that need regex parsing).
#
# However, backtrace_locations returns nil when someone has called
# Exception#set_backtrace with Array<String> — the VM cannot
# reconstruct Location objects from formatted strings. This happens
# in exception wrapping patterns (catch, create new exception, copy
# original's string backtrace via set_backtrace, re-raise).
# In that case, fall back to backtrace strings.
#
# Both accessors use the UnboundMethod trick to bypass subclass
# overrides, consistent with the rest of this method.
#
# If a subclass overrides #backtrace, MRI's raise never stores
# the real backtrace — both paths return nil and stacktrace is [].
# This is unrecoverable without calling customer code.
# See DI::EXCEPTION_BACKTRACE comment for details.
locations = DI::EXCEPTION_BACKTRACE_LOCATIONS.bind(exception).call
stacktrace = if locations
format_backtrace_locations(locations)
else
format_backtrace_strings(DI::EXCEPTION_BACKTRACE.bind(exception).call)
end
{
type: exception.class.name,
message: message,
stacktrace: format_backtrace(exception.backtrace),
stacktrace: stacktrace,
}
end

# Matches Ruby backtrace frame format: "/path/file.rb:42:in `method_name'"
# Captures: $1 = file path, $2 = line number, $3 = method name
BACKTRACE_FRAME_PATTERN = /\A(.+):(\d+):in\s+[`'](.+)'\z/

# Converts backtrace locations into the stack frame format
# expected by the Datadog UI.
#
# Uses Thread::Backtrace::Location objects which provide structured
# path/lineno/label directly, avoiding the round-trip of formatting
# to strings and regex-parsing back.
#
# @param locations [Array<Thread::Backtrace::Location>]
# @return [Array<Hash>]
def format_backtrace_locations(locations)
locations.map do |loc|
{fileName: loc.path, function: loc.label, lineNumber: loc.lineno}
end
end

# Parses Ruby backtrace strings into the stack frame format
# expected by the Datadog UI.
#
# Fallback for when backtrace_locations returns nil (see
# serialize_throwable for details on when this happens).
#
# Ruby backtrace format: "/path/file.rb:42:in `method_name'"
#
# @param backtrace [Array<String>, nil] from Exception#backtrace
# @return [Array<Hash>, nil]
def format_backtrace(backtrace)
# @return [Array<Hash>]
def format_backtrace_strings(backtrace)
return [] if backtrace.nil?

backtrace.map do |frame|
Expand Down
2 changes: 2 additions & 0 deletions sig/datadog/di.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ module Datadog
def self.all_iseqs: () -> Array[RubyVM::InstructionSequence]
def self.file_iseqs: () -> Array[RubyVM::InstructionSequence]
def self.exception_message: (Exception exception) -> untyped
EXCEPTION_BACKTRACE_LOCATIONS: UnboundMethod
EXCEPTION_BACKTRACE: UnboundMethod

def self.component: () -> Component

Expand Down
3 changes: 2 additions & 1 deletion sig/datadog/di/probe_notification_builder.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ module Datadog

def build_snapshot: (Context context) -> Hash[Symbol,untyped]
def serialize_throwable: (Exception exception) -> Hash[Symbol, String? | Array[Hash[Symbol, String | Integer | nil]]?]
def format_backtrace: (Array[String]? backtrace) -> Array[Hash[Symbol, String | Integer | nil]]
def format_backtrace_locations: (Array[Thread::Backtrace::Location] locations) -> Array[Hash[Symbol, String | Integer | nil]]
def format_backtrace_strings: (Array[String]? backtrace) -> Array[Hash[Symbol, String | Integer | nil]]

def build_snapshot_base: (Context context, ?evaluation_errors: Array[untyped]?, ?captures: untyped?, ?message: String?) -> Hash[Symbol,untyped]

Expand Down
150 changes: 150 additions & 0 deletions spec/datadog/di/ext/exception_backtrace_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
require "datadog/di/spec_helper"

RSpec.describe 'EXCEPTION_BACKTRACE_LOCATIONS' do
subject(:backtrace) do
Datadog::DI::EXCEPTION_BACKTRACE_LOCATIONS.bind(exception).call
end

context 'when exception has a backtrace' do
let(:exception) do
raise StandardError, 'test'
rescue => e
e
end

it 'returns an array of Thread::Backtrace::Location' do
expect(backtrace).to be_an(Array)
expect(backtrace).not_to be_empty
expect(backtrace.first).to be_a(Thread::Backtrace::Location)
expect(backtrace.first.path).to be_a(String)
expect(backtrace.first.lineno).to be_a(Integer)
end
end

context 'when exception has no backtrace' do
let(:exception) do
StandardError.new('no backtrace')
end

it 'returns nil' do
expect(backtrace).to be_nil
end
end

context 'when exception class overrides backtrace_locations method' do
let(:exception_class) do
Class.new(StandardError) do
define_method(:backtrace_locations) do
[]
end
end
end

let(:exception) do
raise exception_class, 'test'
rescue => e
e
end

it 'returns the real backtrace, not the overridden one' do
# The UnboundMethod bypasses the subclass override.
expect(backtrace).to be_an(Array)
expect(backtrace).not_to be_empty
expect(backtrace.first).to be_a(Thread::Backtrace::Location)

# Verify the override exists on the Ruby side.
expect(exception.backtrace_locations).to eq([])
end
end

context 'when backtrace was set via set_backtrace with strings' do
let(:exception) do
e = StandardError.new('wrapped')
e.set_backtrace(['/app/foo.rb:10:in `bar\'', '/app/baz.rb:20:in `qux\''])
e
end

it 'returns nil for backtrace_locations' do
# set_backtrace with Array<String> causes backtrace_locations to
# return nil — the VM cannot reconstruct Location objects from
# formatted strings.
expect(backtrace).to be_nil
end
end
end

RSpec.describe 'EXCEPTION_BACKTRACE' do
subject(:backtrace) do
Datadog::DI::EXCEPTION_BACKTRACE.bind(exception).call
end

context 'when exception has a backtrace' do
let(:exception) do
raise StandardError, 'test'
rescue => e
e
end

it 'returns an array of strings' do
expect(backtrace).to be_an(Array)
expect(backtrace).not_to be_empty
expect(backtrace.first).to be_a(String)
end
end

context 'when exception has no backtrace' do
let(:exception) do
StandardError.new('no backtrace')
end

it 'returns nil' do
expect(backtrace).to be_nil
end
end

context 'when exception class overrides backtrace method' do
let(:exception_class) do
Class.new(StandardError) do
define_method(:backtrace) do
['overridden:0:in `fake\'']
end
end
end

let(:exception) do
raise exception_class, 'test'
rescue => e
e
end

it 'returns nil — UnboundMethod does NOT bypass overrides for backtrace' do
# Unlike backtrace_locations, the UnboundMethod trick does not bypass
# subclass overrides of Exception#backtrace. MRI's setup_exception
# (eval.c) calls rb_get_backtrace during raise, which detects the
# #backtrace override and calls it. Since the override returns
# non-nil, setup_exception skips storing the real VM backtrace in
# @bt entirely. The C function exc_backtrace then reads @bt (still
# nil from exc_init) and returns nil.
#
# This is acceptable because EXCEPTION_BACKTRACE is only used as a
# fallback when backtrace_locations returns nil (the set_backtrace
# with strings case), where no subclass override is involved.
expect(backtrace).to be_nil

# Verify the override exists on the Ruby side.
expect(exception.backtrace).to eq(['overridden:0:in `fake\''])
end
end

context 'when backtrace was set via set_backtrace with strings' do
let(:exception) do
e = StandardError.new('wrapped')
e.set_backtrace(['/app/foo.rb:10:in `bar\'', '/app/baz.rb:20:in `qux\''])
e
end

it 'returns the string backtrace' do
expect(backtrace).to eq(['/app/foo.rb:10:in `bar\'', '/app/baz.rb:20:in `qux\''])
end
end
end
Loading
Loading