Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
404cde1
Add execution-context generation token to DI snapshots
p-ddsign Aug 24, 2026
31301fe
Fix DI ThreadGeneration crash on Ruby 2.6
p-ddsign Aug 26, 2026
90ada79
Update DI integration snapshot expectations for generation token
p-ddsign Aug 26, 2026
b19e391
Prefix core/stdlib types with :: in ThreadGeneration RBS
p-ddsign Aug 26, 2026
d691813
Document ThreadGeneration::State and its initializer
p-ddsign Aug 26, 2026
18ade86
Require DI ThreadGeneration from ProbeNotificationBuilder
p-ddsign Oct 3, 2026
985a23b
Align DI ThreadGeneration spec with DI spec conventions
p-ddsign Oct 3, 2026
667b78c
Merge branch 'master' into di-snapshot-generation-token
p-datadog Oct 3, 2026
6de128f
Add changelog fragment for DI snapshot generation token
p-ddsign Oct 3, 2026
a41f48b
Correct DI generation token rationale for the emitted payload
p-ddsign Oct 3, 2026
3b073bc
Remove leading :: from core types in ThreadGeneration RBS
p-ddsign Oct 3, 2026
2a95bbe
DI: add current_thread_generation C primitive
p-ddsign Oct 7, 2026
d2cf3be
DI: emit snapshot generation token via the C primitive
p-ddsign Oct 7, 2026
95c6989
DI: document build_snapshot_base parameters
p-ddsign Oct 7, 2026
cc90609
DI: read generation ivar without verbose-mode warning
p-ddsign Oct 7, 2026
2ae0103
DI: trim generation ivar declaration comment
p-ddsign Oct 7, 2026
f772839
Merge remote-tracking branch 'origin/master' into di-snapshot-generat…
p-ddsign Oct 7, 2026
1be1058
changelog: align the entry with the PR description
Oct 7, 2026
74f5e35
Merge remote-tracking branch 'refs/remotes/origin/master' into di-sna…
Oct 17, 2026
75cda97
DI: assign snapshot generation tokens on frozen threads
Oct 17, 2026
1e9c5b4
DI: correct build_snapshot_base docstring parameter type
Oct 17, 2026
47b34a0
Potential fix for pull request finding 'Missing di_test guard causes …
p-datadog Oct 17, 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
75 changes: 75 additions & 0 deletions ext/libdatadog_api/di.c
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ static ID id_mesg;
// method probes on Thread#[] / Thread#[]= cannot intercept guard reads/writes.
static ID id_datadog_di_in_probe;

// ID for the ivar on each Thread object that holds that thread's snapshot
// generation token.
static ID id_datadog_di_thread_generation;

// Process-wide source of snapshot generation tokens. Incremented only inside
// current_thread_generation, which runs with the GVL held and never releases
// it, so the counter and the accompanying ivar write need no lock.
static unsigned long long generation_counter;
Comment on lines +46 to +49

// Snapshot generation tokens for Thread objects frozen before their first
// snapshot. rb_ivar_set raises FrozenError on a frozen Thread, so those
// tokens are keyed by the Thread object here instead. The table is plain C
// memory invisible to the GC and holds no reference to the Thread, so an
// entry outlives its thread: a Thread allocated later at the same address
// reuses the dead thread's token, and the table grows by one entry per
Comment on lines +55 to +56
// frozen thread that captured a snapshot. Guarded by the GVL like the
// counter above.
static st_table *frozen_thread_generations;

// Returns the imemo type of an imemo object, that is `imemo_type()` from CRuby's internal/imemo.h.
// The caller must have already checked the object is a T_IMEMO, otherwise the result is meaningless.
static inline int ddtrace_imemo_type(VALUE imemo) {
Expand Down Expand Up @@ -151,6 +170,59 @@ static VALUE leave_probe(DDTRACE_UNUSED VALUE _self) {
return Qnil;
}

/*
* Returns the snapshot generation token of a frozen Thread, assigning
* one from the process-wide counter on the first call for the thread.
* Only called for a Thread whose ivar write would raise FrozenError.
*/
static VALUE frozen_thread_generation(VALUE thread) {
st_data_t token;
if (st_lookup(frozen_thread_generations, (st_data_t)thread, &token)) {
return ULL2NUM((unsigned long long)token);
Comment on lines +180 to +181

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove stale frozen-thread token entries

When a frozen thread terminates and Ruby later allocates another Thread at the same VALUE address, this lookup returns the dead thread's token, so two distinct threads emit the same generation value. The table declaration itself notes that entries outlive their threads and addresses can be reused; use GC-aware storage or remove entries when their Thread is collected so the token remains unique per Thread.

Useful? React with 👍 / 👎.

}
token = (st_data_t)++generation_counter;
st_insert(frozen_thread_generations, (st_data_t)thread, token);
return ULL2NUM((unsigned long long)token);
}

/*
* call-seq:
* DI.current_thread_generation -> Integer
*
* Returns the snapshot generation token for the current thread, assigning
* one on the first call for the thread. Snapshots captured by the same
* thread share a token, and snapshots captured by different threads carry
* different tokens, so a consumer can tell which thread captured each
* snapshot. All fibers of a thread share that thread's token. Distinct
* Thread objects get distinct tokens, even when native thread ids are
* reused. Tokens are unique only within a runtime id.
*
* The token is a value of the process-wide counter above, stored in an
* ivar on the Thread object. The ivar is read via rb_attr_get, which
* returns nil for an unset ivar and, unlike rb_ivar_get, emits no
* verbose-mode warning on Ruby 2.6 and 2.7; it is written via rb_ivar_set.
* A Thread frozen before its first snapshot, where writing the ivar would
* raise FrozenError, keeps its token in a process-wide table keyed by the
* Thread object. Both accesses bypass Thread#thread_variable_get and
* Thread#thread_variable_set method dispatch, so a user-installed method
* probe on those methods cannot fire during DI snapshot building.
*
* @return [Integer] the generation token of the current thread
* @api private
*/
static VALUE current_thread_generation(DDTRACE_UNUSED VALUE _self) {
VALUE thread = rb_thread_current();
VALUE generation = rb_attr_get(thread, id_datadog_di_thread_generation);
if (NIL_P(generation)) {
if (OBJ_FROZEN(thread)) {
return frozen_thread_generation(thread);
}
generation = ULL2NUM(++generation_counter);
rb_ivar_set(thread, id_datadog_di_thread_generation, generation);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid storing the token on a frozen Thread

When an application freezes Thread.current before a probed call, the first token lookup reaches this rb_ivar_set, which raises FrozenError because the receiver is frozen. Since every snapshot now calls this primitive, DI then drops all snapshots from that thread under the default rescue behavior, or can propagate the exception in development mode; keep the token in storage that does not mutate the Thread object or provide a fallback for frozen threads.

Useful? React with 👍 / 👎.

}
return generation;
}

/*
* call-seq:
* DI.hash?(obj) -> true | false
Expand Down Expand Up @@ -224,6 +296,8 @@ void di_init(VALUE datadog_module) {

id_mesg = rb_intern("mesg");
id_datadog_di_in_probe = rb_intern("datadog_di_in_probe");
id_datadog_di_thread_generation = rb_intern("@datadog_di_thread_generation");
frozen_thread_generations = st_init_numtable();

VALUE di_module = rb_define_module_under(datadog_module, "DI");
rb_define_singleton_method(di_module, "all_iseqs", all_iseqs, 0);
Expand All @@ -232,6 +306,7 @@ void di_init(VALUE datadog_module) {
rb_define_singleton_method(di_module, "enter_probe", enter_probe, 0);
rb_define_singleton_method(di_module, "leave_probe", leave_probe, 0);
rb_define_singleton_method(di_module, "hash?", is_hash, 1);
rb_define_singleton_method(di_module, "current_thread_generation", current_thread_generation, 0);
#ifdef HAVE_RB_ISEQ_TYPE
rb_define_singleton_method(di_module, "iseq_type", iseq_type, 1);
#endif
Expand Down
9 changes: 9 additions & 0 deletions lib/datadog/di/probe_notification_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,14 @@ def format_backtrace_strings(backtrace)
end
end

# Builds the snapshot payload common to all probe snapshot notifications.
#
# @param context [Context] probe execution context for the snapshot
# @param evaluation_errors [Array<EvaluationFailure>] errors from
# evaluating the probe template or capture expressions
# @param captures [Hash, nil] serialized captured values
# @param message [String, nil] rendered probe template message
# @return [Hash] the snapshot payload
def build_snapshot_base(context, evaluation_errors: [], captures: nil, message: nil)
probe = context.probe

Expand Down Expand Up @@ -379,6 +387,7 @@ def build_snapshot_base(context, evaluation_errors: [], captures: nil, message:
# we can also determine which thread identifier to send
# (Thread#native_thread_id or something else).
thread_id: nil,
generation: DI.current_thread_generation,
version: 2,
},
runtime_id: Core::Environment::Identity.id,
Expand Down
2 changes: 2 additions & 0 deletions sig/datadog/di.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ module Datadog
# args and return are untyped: variadic forwarding to a customer-supplied
# Proc whose signature and return value are unknown to this primitive.

def self.current_thread_generation: () -> Integer

def self.component: () -> Component

def self.instrumented_count: (?instrumentation_kind? kind) -> Integer
Expand Down
116 changes: 116 additions & 0 deletions spec/datadog/di/ext/thread_generation_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
require "datadog/di/spec_helper"

# Unit tests for the C-implemented snapshot generation token primitive:
# Datadog::DI.current_thread_generation. These tests exercise the token's
# semantics: a positive integer, stable for one thread, shared by all fibers
# of a thread, distinct across threads, and still assigned to a Thread
# frozen before its first snapshot.

RSpec.describe "Datadog::DI snapshot generation token" do
Comment thread
Copilot marked this conversation as resolved.
di_test
describe ".current_thread_generation" do
context "for the calling thread" do
let(:token) { Datadog::DI.current_thread_generation }

before do
expect(token).to be_a(Integer)
expect(token).to be > 0
end

it "returns a positive integer" do
expect(token).to be_a(Integer)
expect(token).to be > 0
end

it "returns the same token across calls" do
expect(Datadog::DI.current_thread_generation).to eq(token)
end

it "returns the same token to all fibers of the thread" do
fiber_token = nil
Fiber.new { fiber_token = Datadog::DI.current_thread_generation }.resume

expect(fiber_token).to eq(token)
end
end

context "for a different thread" do
let(:calling_thread_token) { Datadog::DI.current_thread_generation }

before do
expect(calling_thread_token).to be_a(Integer)
expect(calling_thread_token).to be > 0
end

it "returns a token distinct from the calling thread's token" do
other_token = nil
thread = Thread.new { other_token = Datadog::DI.current_thread_generation }
raise "thread wait timeout" unless thread.join(5)

expect(other_token).to be_a(Integer)
expect(other_token).not_to eq(calling_thread_token)
end

it "returns the same token across calls within the thread" do
tokens = []
thread = Thread.new { 3.times { tokens << Datadog::DI.current_thread_generation } }
raise "thread wait timeout" unless thread.join(5)

expect(tokens.first).to be_a(Integer)
expect(tokens.uniq).to eq([tokens.first])
end
end

context "for a thread frozen before its first token" do
it "returns a positive integer" do
token = nil
thread = Thread.new do
Thread.current.freeze
token = Datadog::DI.current_thread_generation
end
raise "thread wait timeout" unless thread.join(5)

expect(token).to be_a(Integer)
expect(token).to be > 0
end

it "returns the same token across calls within the thread" do
tokens = []
thread = Thread.new do
Thread.current.freeze
3.times { tokens << Datadog::DI.current_thread_generation }
end
raise "thread wait timeout" unless thread.join(5)

expect(tokens.first).to be_a(Integer)
expect(tokens.uniq).to eq([tokens.first])
end

it "returns a token distinct from the calling thread's token" do
frozen_thread_token = nil
thread = Thread.new do
Thread.current.freeze
frozen_thread_token = Datadog::DI.current_thread_generation
end
raise "thread wait timeout" unless thread.join(5)

expect(frozen_thread_token).to be_a(Integer)
expect(frozen_thread_token).not_to eq(Datadog::DI.current_thread_generation)
end
end

context "for a thread frozen after its first token" do
it "keeps returning the token assigned before the freeze" do
tokens = []
thread = Thread.new do
tokens << Datadog::DI.current_thread_generation
Thread.current.freeze
tokens << Datadog::DI.current_thread_generation
end
raise "thread wait timeout" unless thread.join(5)

expect(tokens.uniq).to eq([tokens.first])
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def target_method
method: "target_method",
name: nil,
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down Expand Up @@ -480,6 +481,7 @@ def assert_received_and_errored
method: "target_method",
name: nil,
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down Expand Up @@ -659,6 +661,7 @@ def assert_received_and_errored
method: nil,
name: "instrumentation_integration_test_class.rb",
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down Expand Up @@ -746,6 +749,7 @@ def assert_received_and_errored
method: nil,
name: "instrumentation_integration_test_class.rb",
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down
8 changes: 8 additions & 0 deletions spec/datadog/di/probe_notification_builder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@
expect(payload[:runtime_id]).to eq(Datadog::Core::Environment::Identity.id)
end

it "includes the thread generation token in the logger object" do
expect(payload[:logger][:generation]).to be_an(Integer)
expect(payload[:logger][:generation]).to eq(Datadog::DI.current_thread_generation)
end

context "with template" do
let(:probe) do
Datadog::DI::Probe.new(id: "123", type: :log, file: "X", line_no: 1,
Expand Down Expand Up @@ -306,6 +311,7 @@
method: nil,
name: "X",
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down Expand Up @@ -359,6 +365,7 @@
method: nil,
name: "X",
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down Expand Up @@ -436,6 +443,7 @@
method: nil,
name: "X",
thread_id: nil,
generation: Integer,
thread_name: "Thread.main",
version: 2,
},
Expand Down
6 changes: 6 additions & 0 deletions unreleased/6233-di-snapshot-generation-token.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"message": "Dynamic Instrumentation: snapshots now carry an execution-context generation token to disambiguate reused thread ids.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Describe the standalone generation token accurately

Fresh evidence after the earlier thread-field comment is this newly added fragment: it tells customers that the token disambiguates reused thread IDs, but the emitted payload still sets logger.thread_id to nil, so no ID is available to pair with the generation. Describe generation as the standalone per-thread identifier actually emitted instead.

AGENTS.md reference: AGENTS.md:L174-L175

Useful? React with 👍 / 👎.

"product": "Dynamic Instrumentation",
"pull_request": "https://github.com/DataDog/dd-trace-rb/pull/6233",
"type": "Added"
}
Loading