datadog 2.35.0 β†’ 2.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -1
  3. data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
  4. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +71 -31
  5. data/ext/datadog_profiling_native_extension/collectors_discrete_dynamic_sampler.c +1 -1
  6. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +18 -4
  7. data/ext/datadog_profiling_native_extension/collectors_stack.c +37 -18
  8. data/ext/datadog_profiling_native_extension/collectors_stack.h +8 -2
  9. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +596 -341
  10. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +11 -7
  11. data/ext/datadog_profiling_native_extension/datadog_ruby_common.c +7 -8
  12. data/ext/datadog_profiling_native_extension/datadog_ruby_common.h +0 -12
  13. data/ext/datadog_profiling_native_extension/extconf.rb +13 -2
  14. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.c +4 -43
  15. data/ext/datadog_profiling_native_extension/gvl_profiling_helper.h +15 -47
  16. data/ext/datadog_profiling_native_extension/heap_recorder.c +44 -26
  17. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +14 -35
  18. data/ext/datadog_profiling_native_extension/profiling.c +41 -4
  19. data/ext/datadog_profiling_native_extension/ruby_helpers.c +40 -34
  20. data/ext/datadog_profiling_native_extension/stack_recorder.c +24 -3
  21. data/ext/datadog_profiling_native_extension/stack_recorder.h +1 -0
  22. data/ext/datadog_profiling_native_extension/unsafe_api_calls_check.h +4 -2
  23. data/ext/libdatadog_api/datadog_ruby_common.c +7 -8
  24. data/ext/libdatadog_api/datadog_ruby_common.h +0 -12
  25. data/ext/libdatadog_api/di.c +76 -0
  26. data/ext/libdatadog_api/extconf.rb +6 -0
  27. data/ext/libdatadog_extconf_helpers.rb +1 -1
  28. data/lib/datadog/ai_guard/api_client.rb +6 -4
  29. data/lib/datadog/appsec/api_security/route_extractor.rb +13 -4
  30. data/lib/datadog/appsec/component.rb +1 -1
  31. data/lib/datadog/appsec/configuration.rb +13 -0
  32. data/lib/datadog/appsec/context.rb +4 -2
  33. data/lib/datadog/appsec/contrib/aws_lambda/waf_addresses.rb +37 -4
  34. data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
  35. data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
  36. data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +64 -19
  37. data/lib/datadog/appsec/contrib/graphql/integration.rb +1 -0
  38. data/lib/datadog/appsec/contrib/rack/buffered_input.rb +83 -0
  39. data/lib/datadog/appsec/contrib/rack/gateway/request.rb +41 -3
  40. data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +24 -7
  41. data/lib/datadog/appsec/contrib/rack/input_peeker.rb +79 -0
  42. data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
  43. data/lib/datadog/appsec/contrib/rails/gateway/request.rb +35 -1
  44. data/lib/datadog/appsec/contrib/rails/gateway/watcher.rb +17 -1
  45. data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
  46. data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +24 -3
  47. data/lib/datadog/appsec/default_header_tags.rb +10 -6
  48. data/lib/datadog/appsec/ext.rb +1 -0
  49. data/lib/datadog/appsec/metrics/collector.rb +18 -1
  50. data/lib/datadog/appsec/metrics/exporter.rb +9 -3
  51. data/lib/datadog/appsec/remote.rb +9 -3
  52. data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
  53. data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
  54. data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
  55. data/lib/datadog/appsec/route_normalizer.rb +80 -0
  56. data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
  57. data/lib/datadog/core/configuration/components.rb +83 -2
  58. data/lib/datadog/core/configuration/components_state.rb +6 -1
  59. data/lib/datadog/core/configuration/settings.rb +1 -5
  60. data/lib/datadog/core/configuration/supported_configurations.rb +4 -0
  61. data/lib/datadog/core/evp.rb +11 -0
  62. data/lib/datadog/core/remote/client/capabilities.rb +34 -7
  63. data/lib/datadog/core/remote/component.rb +1 -1
  64. data/lib/datadog/core/telemetry/event/app_started.rb +8 -22
  65. data/lib/datadog/core/utils/at_fork_monkey_patch.rb +1 -1
  66. data/lib/datadog/core/utils/forking.rb +3 -1
  67. data/lib/datadog/core/utils/spawn_monkey_patch.rb +3 -1
  68. data/lib/datadog/core.rb +3 -0
  69. data/lib/datadog/di/base.rb +7 -2
  70. data/lib/datadog/di/code_tracker.rb +5 -2
  71. data/lib/datadog/di/component.rb +110 -36
  72. data/lib/datadog/di/error.rb +10 -0
  73. data/lib/datadog/di/fatal_exceptions.rb +26 -0
  74. data/lib/datadog/di/instrumenter.rb +381 -165
  75. data/lib/datadog/di/probe.rb +14 -0
  76. data/lib/datadog/di/probe_file_loader.rb +8 -3
  77. data/lib/datadog/di/probe_manager.rb +67 -9
  78. data/lib/datadog/di/probe_notification_builder.rb +4 -1
  79. data/lib/datadog/di/probe_notifier_worker.rb +52 -32
  80. data/lib/datadog/di/redactor.rb +16 -1
  81. data/lib/datadog/di/remote.rb +175 -14
  82. data/lib/datadog/di/serializer.rb +12 -5
  83. data/lib/datadog/di/transport/input.rb +8 -4
  84. data/lib/datadog/di.rb +63 -0
  85. data/lib/datadog/error_tracking/collector.rb +2 -1
  86. data/lib/datadog/error_tracking/component.rb +2 -2
  87. data/lib/datadog/kit/appsec/events/v2.rb +60 -2
  88. data/lib/datadog/kit/tracing/method_tracer.rb +4 -1
  89. data/lib/datadog/open_feature/component.rb +29 -6
  90. data/lib/datadog/open_feature/configuration.rb +8 -0
  91. data/lib/datadog/open_feature/ext.rb +2 -0
  92. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
  93. data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
  94. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
  95. data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb β†’ flag_eval_metrics_hook.rb} +1 -1
  96. data/lib/datadog/open_feature/native_evaluator.rb +22 -0
  97. data/lib/datadog/open_feature/provider.rb +59 -27
  98. data/lib/datadog/open_feature/transport.rb +55 -1
  99. data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
  100. data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
  101. data/lib/datadog/opentelemetry/sdk/propagator.rb +9 -3
  102. data/lib/datadog/opentelemetry/sdk/span_processor.rb +4 -1
  103. data/lib/datadog/opentelemetry/sdk.rb +19 -0
  104. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
  105. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
  106. data/lib/datadog/profiling/collectors/thread_context.rb +1 -0
  107. data/lib/datadog/profiling/component.rb +13 -15
  108. data/lib/datadog/profiling/ext/dir_monkey_patches.rb +3 -3
  109. data/lib/datadog/ruby_version.rb +25 -0
  110. data/lib/datadog/symbol_database/component.rb +453 -128
  111. data/lib/datadog/symbol_database/configuration.rb +12 -4
  112. data/lib/datadog/symbol_database/extensions.rb +19 -0
  113. data/lib/datadog/symbol_database/extractor.rb +326 -150
  114. data/lib/datadog/symbol_database/file_hash.rb +3 -1
  115. data/lib/datadog/symbol_database/remote.rb +8 -3
  116. data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
  117. data/lib/datadog/symbol_database/uploader.rb +9 -2
  118. data/lib/datadog/symbol_database.rb +22 -0
  119. data/lib/datadog/tracing/configuration/ext.rb +13 -0
  120. data/lib/datadog/tracing/configuration/settings.rb +17 -0
  121. data/lib/datadog/tracing/contrib/configuration/resolver.rb +7 -0
  122. data/lib/datadog/tracing/contrib/grpc/distributed/propagation.rb +2 -0
  123. data/lib/datadog/tracing/contrib/grpc.rb +1 -0
  124. data/lib/datadog/tracing/contrib/http/distributed/propagation.rb +2 -0
  125. data/lib/datadog/tracing/contrib/http.rb +1 -0
  126. data/lib/datadog/tracing/contrib/karafka/distributed/propagation.rb +2 -0
  127. data/lib/datadog/tracing/contrib/karafka.rb +1 -0
  128. data/lib/datadog/tracing/contrib/rack/middlewares.rb +3 -1
  129. data/lib/datadog/tracing/contrib/rack/route_inference.rb +3 -1
  130. data/lib/datadog/tracing/contrib/sidekiq/distributed/propagation.rb +2 -0
  131. data/lib/datadog/tracing/contrib/sidekiq.rb +1 -0
  132. data/lib/datadog/tracing/contrib/waterdrop/distributed/propagation.rb +2 -0
  133. data/lib/datadog/tracing/contrib/waterdrop.rb +1 -0
  134. data/lib/datadog/tracing/distributed/baggage.rb +2 -1
  135. data/lib/datadog/tracing/distributed/propagation.rb +33 -1
  136. data/lib/datadog/tracing/distributed/trace_context.rb +11 -2
  137. data/lib/datadog/tracing/ext.rb +9 -0
  138. data/lib/datadog/tracing/remote.rb +34 -3
  139. data/lib/datadog/tracing/trace_digest.rb +7 -0
  140. data/lib/datadog/tracing/trace_operation.rb +4 -1
  141. data/lib/datadog/tracing/tracer.rb +1 -0
  142. data/lib/datadog/version.rb +1 -1
  143. data/lib/datadog.rb +5 -1
  144. metadata +22 -6
@@ -1,4 +1,5 @@
1
1
  #include <ruby.h>
2
+ #include <ruby/debug.h>
2
3
 
3
4
  #include "datadog_ruby_common.h"
4
5
  #include "collectors_thread_context.h"
@@ -76,14 +77,15 @@
76
77
  #define THREAD_ID_LIMIT_CHARS 44 // Why 44? "#{2**64} (#{2**64})".size + 1 for \0
77
78
  #define THREAD_INVOKE_LOCATION_LIMIT_CHARS 512
78
79
  #define IS_WALL_TIME true
79
- #define IS_NOT_WALL_TIME false
80
+ #define IS_CPU_TIME false
80
81
  #define MISSING_TRACER_CONTEXT_KEY 0
81
82
  #define TIME_BETWEEN_GC_EVENTS_NS MILLIS_AS_NS(10)
83
+ #define GVL_SUSPENDED ((uint64_t)1)
84
+ #define GVL_RUNNING ((uint64_t)0)
82
85
 
83
- // This is used as a placeholder to mark threads that are allowed to be profiled (enabled)
84
- // (e.g. to avoid trying to gvl profile threads that are not from the main Ractor)
85
- // and for which there's no data yet
86
- #define GVL_WAITING_ENABLED_EMPTY RUBY_FIXNUM_MAX
86
+ #define MAX(a, b) ((a) < (b) ? (b) : (a))
87
+
88
+ static ID dd_per_thread_context_id; // Hidden ivar (no @ prefix, inaccessible from Ruby)
87
89
 
88
90
  static ID at_active_span_id; // id of :@active_span in Ruby
89
91
  static ID at_active_trace_id; // id of :@active_trace in Ruby
@@ -107,12 +109,15 @@ static ID server_id; // id of :server in Ruby
107
109
  static ID otel_context_storage_id; // id of :__opentelemetry_context_storage__ in Ruby
108
110
  static ID otel_fiber_context_storage_id; // id of :@opentelemetry_context in Ruby
109
111
 
110
- // This is used by `thread_context_collector_on_gvl_running`. Because when that method gets called we're not sure if
111
- // it's safe to access the state of the thread context collector, we store this setting as a global value. This does
112
- // mean this setting is shared among all thread context collectors, and thus it's "last writer wins".
113
- // In production this should not be a problem: there should only be one profiler, which is the last one created,
114
- // and that'll be the one that last wrote this setting.
115
- static uint32_t global_waiting_for_gvl_threshold_ns = MILLIS_AS_NS(10);
112
+ // This is mutable and gets set last-writer-wins style whenever a new `ThreadContext` is created.
113
+ //
114
+ // The initial value should be kept in sync with the default for DD_PROFILING_MAX_FRAMES
115
+ // in settings.rb. See `initialize_context` for details on why this is needed/used.
116
+ static uint16_t latest_max_frames = 400;
117
+
118
+ // Global tracepoint for RUBY_EVENT_THREAD_BEGIN. Created and enabled once when the first ThreadContext collector is initialized.
119
+ static VALUE thread_begin_tracepoint = Qnil;
120
+
116
121
 
117
122
  typedef enum { OTEL_CONTEXT_ENABLED_FALSE, OTEL_CONTEXT_ENABLED_ONLY, OTEL_CONTEXT_ENABLED_BOTH } otel_context_enabled;
118
123
  typedef enum { OTEL_CONTEXT_SOURCE_UNKNOWN, OTEL_CONTEXT_SOURCE_FIBER_IVAR, OTEL_CONTEXT_SOURCE_FIBER_LOCAL } otel_context_source;
@@ -122,22 +127,14 @@ typedef struct {
122
127
  // Note: Places in this file that usually need to be changed when this struct is changed are tagged with
123
128
  // "Update this when modifying state struct"
124
129
 
125
- // Required by Datadog::Profiling::Collectors::Stack as a scratch buffer during sampling
126
- ddog_prof_Location *locations;
127
- uint16_t max_frames;
128
- // Hashmap <Thread Object, per_thread_context>
129
- // Note: Be very careful when mutating this map, as it gets read e.g. in the middle of GC and signal handlers.
130
- st_table *hash_map_per_thread_context;
130
+ // Output buffer for stack traces, passed to sample_thread()
131
+ sample_locations locations;
131
132
  // Datadog::Profiling::StackRecorder instance
132
133
  VALUE recorder_instance;
133
134
  // If the tracer is available and enabled, this will be the fiber-local symbol for accessing its running context,
134
135
  // to enable code hotspots and endpoint aggregation.
135
136
  // When not available, this is set to MISSING_TRACER_CONTEXT_KEY.
136
137
  ID tracer_context_key;
137
- // Track how many regular samples we've taken. Does not include garbage collection samples.
138
- // Currently **outside** of stats struct because we also use it to decide when to clean the contexts, and thus this
139
- // is not (just) a stat.
140
- unsigned int sample_count;
141
138
  // Reusable array to get list of threads
142
139
  VALUE thread_list_buffer;
143
140
  // Used to omit endpoint names (retrieved from tracer) from collected data
@@ -158,12 +155,22 @@ typedef struct {
158
155
  bool native_filenames_enabled;
159
156
  // Used to cache native filename lookup results (Map[void *function_pointer, char *filename])
160
157
  st_table *native_filenames_cache;
158
+ // Used to attribute overhead during sampling to this component
159
+ VALUE overhead_filename;
160
+ // Minimum duration of a "Waiting for GVL" period to trigger a sample
161
+ uint32_t waiting_for_gvl_threshold_ns;
161
162
 
162
163
  struct stats {
164
+ // Track how many regular samples we've taken. Does not include garbage collection samples.
165
+ unsigned int sample_count;
163
166
  // Track how many garbage collection samples we've taken.
164
167
  unsigned int gc_samples;
165
168
  // See thread_context_collector_on_gc_start for details
166
169
  unsigned int gc_samples_missed_due_to_missing_context;
170
+ // How many per-thread samples were skipped because the thread has been continuously suspended
171
+ // (no GVL) since its previous sample, so its Ruby stack cannot have changed.
172
+ unsigned int inactive_thread_samples_skipped;
173
+ unsigned int profiler_thread_samples_skipped;
167
174
  } stats;
168
175
 
169
176
  struct {
@@ -175,16 +182,89 @@ typedef struct {
175
182
  } gc_tracking;
176
183
  } thread_context_collector_state;
177
184
 
178
- // Tracks per-thread state
179
- typedef struct {
185
+ // Tracks per-thread state.
186
+ // This state is global and lives forever on the Ruby Thread (until the Thread is GC'd).
187
+ // The state is created early on for all threads on the main Ractor
188
+ // (enabling a TracePoint only enables it for the current Ractor).
189
+ // The state is either created when the Thread starts running (via the RUBY_EVENT_THREAD_BEGIN TracePoint),
190
+ // or the first time we create a ThreadContext by iterating Thread.list.
191
+ // Unfortunately that RUBY_EVENT_THREAD_BEGIN TracePoint still fires after some other events:
192
+ // * RUBY_INTERNAL_THREAD_EVENT_RESUMED for the Thread acquiring the GVL for the first time
193
+ // * an early SIGPROF calling handle_sampling_signal()
194
+ // * Ruby might check for interrupts and run postponed jobs (e.g. thread_context_collector_sample)
195
+ // * another RUBY_EVENT_THREAD_BEGIN TracePoint which might run before ours
196
+ // * etc
197
+ // For those cases we have to ignore those events and we cannot assume the state is always set,
198
+ // however this only matters for a very short period when a thread starts.
199
+ struct per_thread_context {
180
200
  sampling_buffer sampling_buffer;
181
201
  char thread_id[THREAD_ID_LIMIT_CHARS];
182
202
  ddog_CharSlice thread_id_char_slice;
183
203
  char thread_invoke_location[THREAD_INVOKE_LOCATION_LIMIT_CHARS];
184
204
  ddog_CharSlice thread_invoke_location_char_slice;
185
205
  thread_cpu_time_id thread_cpu_time_id;
186
- long cpu_time_at_previous_sample_ns; // Can be INVALID_TIME until initialized or if getting it fails for another reason
187
- long wall_time_at_previous_sample_ns; // Can be INVALID_TIME until initialized
206
+ long cpu_time_at_previous_sample_ns;
207
+ long wall_time_at_previous_sample_ns;
208
+
209
+ // There are 3 possible states for the GVL (per thread), and 3 transitions for which we receive GVL events:
210
+ // Thread holds the GVL
211
+ // on_gvl_released() the thread releases the GVL (RUBY_INTERNAL_THREAD_EVENT_SUSPENDED)
212
+ // Thread runs without the GVL
213
+ // on_gvl_waiting() the thread wants the GVL (RUBY_INTERNAL_THREAD_EVENT_READY)
214
+ // Thread is "Waiting for GVL"
215
+ // on_gvl_running() the thread now got the GVL (RUBY_INTERNAL_THREAD_EVENT_RESUMED)
216
+ // ... and the cycle restarts
217
+
218
+
219
+ // --- GVL waiting tracking state machine ---
220
+ //
221
+ // gvl_waiting_at tracks the GVL wait state for each profiled thread:
222
+ //
223
+ // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
224
+ // β”‚ on_gvl_waiting β”‚
225
+ // β”‚ β–Ό
226
+ // Not Waiting (0) ◀────────────────── Waiting (> 0)
227
+ // β–² on_gvl_running β”‚
228
+ // β”‚ (below threshold) β”‚ on_gvl_running (above threshold)
229
+ // β”‚ β–Ό
230
+ // └─────────────────────────── Sample Pending (< 0)
231
+ // sample / sample_after_gvl_running
232
+ //
233
+ // Not Waiting (0): thread is running or not waiting for the GVL
234
+ // Waiting (> 0): monotonic wall time (ns) when the thread started waiting
235
+ // Sample Pending (< 0): negated timestamp; the wait ended and a sample is pending
236
+ //
237
+ // The field is accessed under the GVL for most functions EXCEPT on_gvl_waiting() which writes to it without the GVL.
238
+ // So we need to pack the above state in a single long to ensure atomicity.
239
+ long gvl_waiting_at;
240
+
241
+ // Per-thread "state + version" word, updated on every GVL state transition. The encoding is:
242
+ // - low bit: current state (1 = currently suspended, 0 = currently running)
243
+ // - bits 1+: monotonic event counter (incremented on every RESUMED)
244
+ // The hooks set the state bit explicitly rather than relying on parity, so the encoding stays
245
+ // correct even when events are not paired properly (as in tests).
246
+ //
247
+ // Note that SUSPENDED can happen multiple times in a row on Ruby 3.2,
248
+ // see https://github.com/DataDog/dd-trace-rb/pull/5777#discussion_r3388560254,
249
+ // the encoding is designed to naturally not change the field in such a case.
250
+ uint64_t gvl_state_change_count;
251
+ // Snapshot of the thread's gvl_state_change_count at the moment we last sampled it.
252
+ // Equality with this snapshot means no GVL transition since the last sample.
253
+ uint64_t gvl_state_change_count_at_previous_sample;
254
+ // True when the previous per-tick sample was skipped by the SUSPENDED-skip optimization, so the
255
+ // flush-before-serialize pass knows it needs to report this thread.
256
+ // As a result, we will accumulate all wall & CPU time as a single batch per reporting period,
257
+ // but this is deemed worth it for this optimization. In any case we don't know exactly
258
+ // at what time a thread was doing CPU work (unless it's on CPU 100% of the time).
259
+ bool was_skipped_at_last_sample;
260
+ // Set as true for CpuAndWallTimeWorker and IdleSamplingHelper threads.
261
+ // When true, per-tick samples are skipped entirely; the thread is sampled only once per
262
+ // reporting period during the on_serialize flush.
263
+ //
264
+ // These threads are always in native code so their stacks aren't interesting;
265
+ // the Profiling::Scheduler thread on the other hand does a lot of different
266
+ // things using a mix of Ruby and native code, so that one isn't considered internal.
267
+ bool is_profiler_internal_thread;
188
268
 
189
269
  struct {
190
270
  // Both of these fields are set by on_gc_start and kept until on_gc_finish is called.
@@ -192,7 +272,7 @@ typedef struct {
192
272
  long cpu_time_at_start_ns;
193
273
  long wall_time_at_start_ns;
194
274
  } gc_tracking;
195
- } per_thread_context;
275
+ };
196
276
 
197
277
  // Used to correlate profiles with traces
198
278
  typedef struct {
@@ -210,29 +290,26 @@ typedef struct {
210
290
 
211
291
  static void thread_context_collector_typed_data_mark(void *state_ptr);
212
292
  static void thread_context_collector_typed_data_free(void *state_ptr);
213
- static int hash_map_per_thread_context_mark(st_data_t key_thread, st_data_t value_thread_context, DDTRACE_UNUSED st_data_t _argument);
214
- static int hash_map_per_thread_context_free_values(st_data_t _thread, st_data_t value_per_thread_context, st_data_t _argument);
293
+ static void per_thread_context_typed_data_mark(void *ctx_ptr);
294
+ static void per_thread_context_typed_data_free(void *ctx_ptr);
215
295
  static VALUE _native_new(VALUE klass);
216
296
  static VALUE _native_initialize(int argc, VALUE *argv, DDTRACE_UNUSED VALUE _self);
217
- static VALUE _native_sample(VALUE self, VALUE collector_instance, VALUE profiler_overhead_stack_thread, VALUE allow_exception);
297
+ static VALUE _native_sample(VALUE self, VALUE collector_instance, VALUE allow_exception);
218
298
  static VALUE _native_on_gc_start(VALUE self, VALUE collector_instance);
219
299
  static VALUE _native_on_gc_finish(VALUE self, VALUE collector_instance);
220
300
  static VALUE _native_sample_after_gc(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE allow_exception);
221
301
  static void update_metrics_and_sample(
222
302
  thread_context_collector_state *state,
223
303
  VALUE thread_being_sampled,
224
- VALUE stack_from_thread,
225
304
  per_thread_context *thread_context,
226
- sampling_buffer* sampling_buffer,
227
305
  long current_cpu_time_ns,
228
- long current_monotonic_wall_time_ns
306
+ long current_monotonic_wall_time_ns,
307
+ bool force_sample
229
308
  );
230
309
  static void trigger_sample_for_thread(
231
310
  thread_context_collector_state *state,
232
- VALUE thread,
233
- VALUE stack_from_thread,
311
+ VALUE thread_being_sampled,
234
312
  per_thread_context *thread_context,
235
- sampling_buffer* sampling_buffer,
236
313
  sample_values values,
237
314
  long current_monotonic_wall_time_ns,
238
315
  ddog_CharSlice *ruby_vm_type,
@@ -241,17 +318,13 @@ static void trigger_sample_for_thread(
241
318
  bool is_safe_to_allocate_objects
242
319
  );
243
320
  static VALUE _native_thread_list(VALUE self);
244
- static per_thread_context *get_or_create_context_for(VALUE thread, thread_context_collector_state *state);
245
- static per_thread_context *get_context_for(VALUE thread, thread_context_collector_state *state);
246
- static void initialize_context(VALUE thread, per_thread_context *thread_context, thread_context_collector_state *state);
247
- static void free_context(per_thread_context* thread_context);
321
+ static void check_frozen_thread(VALUE thread);
322
+ static per_thread_context *get_or_create_context_for(VALUE thread);
323
+ static void initialize_context(VALUE thread, per_thread_context *thread_context);
248
324
  static VALUE _native_inspect(VALUE self, VALUE collector_instance);
249
- static VALUE per_thread_context_st_table_as_ruby_hash(thread_context_collector_state *state);
250
- static int per_thread_context_as_ruby_hash(st_data_t key_thread, st_data_t value_context, st_data_t result_hash);
251
- static VALUE stats_as_ruby_hash(thread_context_collector_state *state);
325
+ static VALUE per_thread_context_to_ruby_hash(per_thread_context *thread_context);
326
+ static VALUE stats_to_ruby_hash(thread_context_collector_state *state, VALUE hash);
252
327
  static VALUE gc_tracking_as_ruby_hash(thread_context_collector_state *state);
253
- static void remove_context_for_dead_threads(thread_context_collector_state *state);
254
- static int remove_if_dead_thread(st_data_t key_thread, st_data_t value_context, st_data_t _argument);
255
328
  static VALUE _native_per_thread_context(VALUE self, VALUE collector_instance);
256
329
  static long update_time_since_previous_sample(long *time_at_previous_sample_ns, long current_time_ns, long gc_start_time_ns, bool is_wall_time);
257
330
  static long cpu_time_now_ns(per_thread_context *thread_context);
@@ -283,18 +356,17 @@ static VALUE _native_sample_skipped_allocation_samples(DDTRACE_UNUSED VALUE self
283
356
  static bool handle_gvl_waiting(
284
357
  thread_context_collector_state *state,
285
358
  VALUE thread_being_sampled,
286
- VALUE stack_from_thread,
287
359
  per_thread_context *thread_context,
288
- sampling_buffer* sampling_buffer,
289
360
  long current_cpu_time_ns
290
361
  );
291
362
  #ifndef NO_GVL_INSTRUMENTATION
292
363
  static VALUE _native_on_gvl_waiting(DDTRACE_UNUSED VALUE self, VALUE thread);
293
364
  static VALUE _native_gvl_waiting_at_for(DDTRACE_UNUSED VALUE self, VALUE thread);
294
- static VALUE _native_on_gvl_running(DDTRACE_UNUSED VALUE self, VALUE thread);
365
+ static VALUE _native_on_gvl_running(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread);
366
+ static VALUE _native_on_gvl_released(DDTRACE_UNUSED VALUE self, VALUE thread);
295
367
  static VALUE _native_sample_after_gvl_running(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread, VALUE allow_exception);
296
- static VALUE _native_apply_delta_to_cpu_time_at_previous_sample_ns(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread, VALUE delta_ns);
297
368
  #endif
369
+ static VALUE _native_apply_delta_to_cpu_time_at_previous_sample_ns(DDTRACE_UNUSED VALUE self, VALUE thread, VALUE delta_ns);
298
370
  static void otel_without_ddtrace_trace_identifiers_for(
299
371
  thread_context_collector_state *state,
300
372
  VALUE thread,
@@ -305,11 +377,17 @@ static otel_span otel_span_from(VALUE otel_context, VALUE otel_current_span_key)
305
377
  static uint64_t otel_span_id_to_uint(VALUE otel_span_id);
306
378
  static VALUE safely_lookup_hash_without_going_into_ruby_code(VALUE hash, VALUE key);
307
379
  static VALUE _native_system_epoch_time_now_ns(DDTRACE_UNUSED VALUE self, VALUE collector_instance);
308
- static VALUE _native_prepare_sample_inside_signal_handler(DDTRACE_UNUSED VALUE self, VALUE collector_instance);
380
+ static VALUE _native_prepare_sample_inside_signal_handler(DDTRACE_UNUSED VALUE self);
381
+ static VALUE _native_clear_per_thread_context_for(DDTRACE_UNUSED VALUE self, VALUE thread);
382
+ static VALUE _native_mark_thread_as_profiler_internal(DDTRACE_UNUSED VALUE self, VALUE thread);
383
+ static VALUE _native_remove_per_thread_context_for(DDTRACE_UNUSED VALUE self, VALUE thread);
384
+ static bool skip_sample(thread_context_collector_state *state, per_thread_context *thread_context, bool is_gvl_waiting_state, bool force_sample);
385
+ static void on_thread_begin_event(VALUE tracepoint_data, DDTRACE_UNUSED void *unused);
309
386
 
310
387
  void collectors_thread_context_init(VALUE profiling_module) {
311
388
  VALUE collectors_module = rb_define_module_under(profiling_module, "Collectors");
312
389
  VALUE collectors_thread_context_class = rb_define_class_under(collectors_module, "ThreadContext", rb_cObject);
390
+
313
391
  // Hosts methods used for testing the native code using RSpec
314
392
  VALUE testing_module = rb_define_module_under(collectors_thread_context_class, "Testing");
315
393
 
@@ -326,7 +404,7 @@ void collectors_thread_context_init(VALUE profiling_module) {
326
404
  rb_define_singleton_method(collectors_thread_context_class, "_native_initialize", _native_initialize, -1);
327
405
  rb_define_singleton_method(collectors_thread_context_class, "_native_inspect", _native_inspect, 1);
328
406
  rb_define_singleton_method(collectors_thread_context_class, "_native_reset_after_fork", _native_reset_after_fork, 1);
329
- rb_define_singleton_method(testing_module, "_native_sample", _native_sample, 3);
407
+ rb_define_singleton_method(testing_module, "_native_sample", _native_sample, 2);
330
408
  rb_define_singleton_method(testing_module, "_native_sample_allocation", _native_sample_allocation, 3);
331
409
  rb_define_singleton_method(testing_module, "_native_on_gc_start", _native_on_gc_start, 1);
332
410
  rb_define_singleton_method(testing_module, "_native_on_gc_finish", _native_on_gc_finish, 1);
@@ -338,14 +416,18 @@ void collectors_thread_context_init(VALUE profiling_module) {
338
416
  rb_define_singleton_method(testing_module, "_native_new_empty_thread", _native_new_empty_thread, 0);
339
417
  rb_define_singleton_method(testing_module, "_native_sample_skipped_allocation_samples", _native_sample_skipped_allocation_samples, 2);
340
418
  rb_define_singleton_method(testing_module, "_native_system_epoch_time_now_ns", _native_system_epoch_time_now_ns, 1);
341
- rb_define_singleton_method(testing_module, "_native_prepare_sample_inside_signal_handler", _native_prepare_sample_inside_signal_handler, 1);
419
+ rb_define_singleton_method(testing_module, "_native_prepare_sample_inside_signal_handler", _native_prepare_sample_inside_signal_handler, 0);
420
+ rb_define_singleton_method(testing_module, "_native_clear_per_thread_context_for", _native_clear_per_thread_context_for, 1);
421
+ rb_define_singleton_method(testing_module, "_native_remove_per_thread_context_for", _native_remove_per_thread_context_for, 1);
422
+ rb_define_singleton_method(testing_module, "_native_mark_thread_as_profiler_internal", _native_mark_thread_as_profiler_internal, 1);
342
423
  #ifndef NO_GVL_INSTRUMENTATION
343
424
  rb_define_singleton_method(testing_module, "_native_on_gvl_waiting", _native_on_gvl_waiting, 1);
344
425
  rb_define_singleton_method(testing_module, "_native_gvl_waiting_at_for", _native_gvl_waiting_at_for, 1);
345
- rb_define_singleton_method(testing_module, "_native_on_gvl_running", _native_on_gvl_running, 1);
426
+ rb_define_singleton_method(testing_module, "_native_on_gvl_running", _native_on_gvl_running, 2);
427
+ rb_define_singleton_method(testing_module, "_native_on_gvl_released", _native_on_gvl_released, 1);
346
428
  rb_define_singleton_method(testing_module, "_native_sample_after_gvl_running", _native_sample_after_gvl_running, 3);
347
- rb_define_singleton_method(testing_module, "_native_apply_delta_to_cpu_time_at_previous_sample_ns", _native_apply_delta_to_cpu_time_at_previous_sample_ns, 3);
348
429
  #endif
430
+ rb_define_singleton_method(testing_module, "_native_apply_delta_to_cpu_time_at_previous_sample_ns", _native_apply_delta_to_cpu_time_at_previous_sample_ns, 2);
349
431
 
350
432
  at_active_span_id = rb_intern_const("@active_span");
351
433
  at_active_trace_id = rb_intern_const("@active_trace");
@@ -366,10 +448,12 @@ void collectors_thread_context_init(VALUE profiling_module) {
366
448
  otel_context_storage_id = rb_intern_const("__opentelemetry_context_storage__");
367
449
  otel_fiber_context_storage_id = rb_intern_const("@opentelemetry_context");
368
450
 
369
- #ifndef NO_GVL_INSTRUMENTATION
370
- // This will raise if Ruby already ran out of thread-local keys
371
- gvl_profiling_init();
372
- #endif
451
+ dd_per_thread_context_id = rb_intern_const("dd_per_thread_context");
452
+
453
+ // This will raise if Ruby already ran out of thread-local keys
454
+ per_thread_context_tls_init();
455
+
456
+ rb_global_variable(&thread_begin_tracepoint);
373
457
 
374
458
  gc_profiling_init();
375
459
  }
@@ -394,10 +478,10 @@ static void thread_context_collector_typed_data_mark(void *state_ptr) {
394
478
 
395
479
  // Update this when modifying state struct
396
480
  rb_gc_mark(state->recorder_instance);
397
- st_foreach(state->hash_map_per_thread_context, hash_map_per_thread_context_mark, 0 /* unused */);
398
481
  rb_gc_mark(state->thread_list_buffer);
399
482
  rb_gc_mark(state->main_thread);
400
483
  rb_gc_mark(state->otel_current_span_key);
484
+ rb_gc_mark(state->overhead_filename);
401
485
  }
402
486
 
403
487
  static void thread_context_collector_typed_data_free(void *state_ptr) {
@@ -407,36 +491,54 @@ static void thread_context_collector_typed_data_free(void *state_ptr) {
407
491
 
408
492
  // Important: Remember that we're only guaranteed to see here what's been set in _native_new, aka
409
493
  // pointers that have been set NULL there may still be NULL here.
410
- if (state->locations != NULL) ruby_xfree(state->locations);
411
-
412
- // Free each entry in the map
413
- st_foreach(state->hash_map_per_thread_context, hash_map_per_thread_context_free_values, 0 /* unused */);
414
- // ...and then the map
415
- st_free_table(state->hash_map_per_thread_context);
494
+ if (state->locations.ptr != NULL) ruby_xfree(state->locations.ptr);
416
495
 
417
496
  st_free_table(state->native_filenames_cache);
418
497
 
419
498
  ruby_xfree(state);
420
499
  }
421
500
 
422
- // Mark Ruby thread references we keep as keys in hash_map_per_thread_context
423
- static int hash_map_per_thread_context_mark(st_data_t key_thread, st_data_t value_thread_context, DDTRACE_UNUSED st_data_t _argument) {
424
- VALUE thread = (VALUE) key_thread;
425
- per_thread_context *thread_context = (per_thread_context *) value_thread_context;
501
+ // per_thread_context is wrapped in a TypedData Ruby object stored as an ivar on each Ruby Thread.
502
+ // This gives us automatic GC marking (for sampling_buffer iseq VALUEs) and lifecycle management.
503
+ static const rb_data_type_t per_thread_context_typed_data = {
504
+ .wrap_struct_name = "Datadog::Profiling::PerThreadContext",
505
+ .function = {
506
+ .dmark = per_thread_context_typed_data_mark,
507
+ .dfree = per_thread_context_typed_data_free,
508
+ .dsize = NULL,
509
+ },
510
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY,
511
+ };
426
512
 
427
- rb_gc_mark(thread);
428
- if (sampling_buffer_needs_marking(&thread_context->sampling_buffer)) {
429
- sampling_buffer_mark(&thread_context->sampling_buffer);
513
+ static void per_thread_context_typed_data_mark(void *ctx_ptr) {
514
+ per_thread_context *ctx = (per_thread_context *) ctx_ptr;
515
+ if (sampling_buffer_needs_marking(&ctx->sampling_buffer)) {
516
+ sampling_buffer_mark(&ctx->sampling_buffer);
430
517
  }
518
+ }
431
519
 
432
- return ST_CONTINUE;
520
+ static void per_thread_context_typed_data_free(void *ctx_ptr) {
521
+ per_thread_context *ctx = (per_thread_context *) ctx_ptr;
522
+ sampling_buffer_free(&ctx->sampling_buffer);
523
+ free(ctx);
433
524
  }
434
525
 
435
- // Used to clear each of the per_thread_contexts inside the hash_map_per_thread_context
436
- static int hash_map_per_thread_context_free_values(DDTRACE_UNUSED st_data_t _thread, st_data_t value_per_thread_context, DDTRACE_UNUSED st_data_t _argument) {
437
- per_thread_context *thread_context = (per_thread_context*) value_per_thread_context;
438
- free_context(thread_context);
439
- return ST_CONTINUE;
526
+ static VALUE _native_clear_per_thread_context_for(VALUE self, VALUE thread) {
527
+ _native_remove_per_thread_context_for(self, thread);
528
+ get_or_create_context_for(thread);
529
+ return Qnil;
530
+ }
531
+
532
+ // Only for testing: removes the per-thread context without recreating it, so the thread has no context.
533
+ // This simulates a thread that starts running before the RUBY_EVENT_THREAD_BEGIN tracepoint fires.
534
+ static VALUE _native_remove_per_thread_context_for(DDTRACE_UNUSED VALUE self, VALUE thread) {
535
+ check_frozen_thread(thread);
536
+ per_thread_context *ctx = get_per_thread_context(thread);
537
+ if (ctx != NULL) {
538
+ set_per_thread_context(thread, NULL);
539
+ rb_ivar_set(thread, dd_per_thread_context_id, Qnil);
540
+ }
541
+ return Qnil;
440
542
  }
441
543
 
442
544
  static VALUE _native_new(VALUE klass) {
@@ -446,11 +548,8 @@ static VALUE _native_new(VALUE klass) {
446
548
  // being leaked.
447
549
 
448
550
  // Update this when modifying state struct
449
- state->locations = NULL;
450
- state->max_frames = 0;
451
- state->hash_map_per_thread_context =
452
- // "numtable" is an awful name, but TL;DR it's what should be used when keys are `VALUE`s.
453
- st_init_numtable();
551
+ state->locations.ptr = NULL;
552
+ state->locations.len = 0;
454
553
  state->recorder_instance = Qnil;
455
554
  state->tracer_context_key = MISSING_TRACER_CONTEXT_KEY;
456
555
  VALUE thread_list_buffer = rb_ary_new();
@@ -492,22 +591,28 @@ static VALUE _native_initialize(int argc, VALUE *argv, DDTRACE_UNUSED VALUE _sel
492
591
  VALUE waiting_for_gvl_threshold_ns = rb_hash_fetch(options, ID2SYM(rb_intern("waiting_for_gvl_threshold_ns")));
493
592
  VALUE otel_context_enabled = rb_hash_fetch(options, ID2SYM(rb_intern("otel_context_enabled")));
494
593
  VALUE native_filenames_enabled = rb_hash_fetch(options, ID2SYM(rb_intern("native_filenames_enabled")));
594
+ VALUE overhead_filename = rb_hash_fetch(options, ID2SYM(rb_intern("overhead_filename")));
495
595
 
496
596
  ENFORCE_TYPE(max_frames, T_FIXNUM);
497
597
  ENFORCE_BOOLEAN(endpoint_collection_enabled);
498
598
  ENFORCE_TYPE(waiting_for_gvl_threshold_ns, T_FIXNUM);
499
599
  ENFORCE_BOOLEAN(native_filenames_enabled);
600
+ ENFORCE_TYPE(overhead_filename, T_STRING);
601
+
602
+ uint16_t max_frame_int = sampling_buffer_check_max_frames(NUM2INT(max_frames));
603
+ latest_max_frames = max_frame_int;
500
604
 
501
605
  thread_context_collector_state *state;
502
606
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
503
607
 
504
608
  // Update this when modifying state struct
505
- state->max_frames = sampling_buffer_check_max_frames(NUM2INT(max_frames));
506
- state->locations = ruby_xcalloc(state->max_frames, sizeof(ddog_prof_Location));
507
- // hash_map_per_thread_context is already initialized, nothing to do here
609
+ state->locations.len = max_frame_int;
610
+ state->locations.ptr = ruby_xcalloc(max_frame_int, sizeof(ddog_prof_Location));
508
611
  state->recorder_instance = enforce_recorder_instance(recorder_instance);
612
+ recorder_install_on_serialize(recorder_instance, self_instance);
509
613
  state->endpoint_collection_enabled = (endpoint_collection_enabled == Qtrue);
510
614
  state->native_filenames_enabled = (native_filenames_enabled == Qtrue);
615
+ state->overhead_filename = overhead_filename;
511
616
  if (otel_context_enabled == Qfalse || otel_context_enabled == Qnil) {
512
617
  state->otel_context_enabled = OTEL_CONTEXT_ENABLED_FALSE;
513
618
  } else if (otel_context_enabled == ID2SYM(rb_intern("only"))) {
@@ -518,7 +623,7 @@ static VALUE _native_initialize(int argc, VALUE *argv, DDTRACE_UNUSED VALUE _sel
518
623
  raise_error(rb_eArgError, "Unexpected value for otel_context_enabled: %+" PRIsVALUE, otel_context_enabled);
519
624
  }
520
625
 
521
- global_waiting_for_gvl_threshold_ns = NUM2UINT(waiting_for_gvl_threshold_ns);
626
+ state->waiting_for_gvl_threshold_ns = NUM2UINT(waiting_for_gvl_threshold_ns);
522
627
 
523
628
  if (RTEST(tracer_context_key)) {
524
629
  ENFORCE_TYPE(tracer_context_key, T_SYMBOL);
@@ -528,19 +633,30 @@ static VALUE _native_initialize(int argc, VALUE *argv, DDTRACE_UNUSED VALUE _sel
528
633
  state->tracer_context_key = rb_to_id(tracer_context_key);
529
634
  }
530
635
 
636
+ if (thread_begin_tracepoint == Qnil) {
637
+ thread_begin_tracepoint = rb_tracepoint_new(Qnil, RUBY_EVENT_THREAD_BEGIN, on_thread_begin_event, NULL);
638
+ rb_tracepoint_enable(thread_begin_tracepoint);
639
+
640
+ VALUE thread_list = rb_ary_new();
641
+ ddtrace_thread_list(thread_list);
642
+ long thread_count = RARRAY_LEN(thread_list);
643
+ for (long i = 0; i < thread_count; i++) {
644
+ get_or_create_context_for(RARRAY_AREF(thread_list, i));
645
+ }
646
+ RB_GC_GUARD(thread_list);
647
+ }
648
+
531
649
  return Qtrue;
532
650
  }
533
651
 
534
652
  // This method exists only to enable testing Datadog::Profiling::Collectors::ThreadContext behavior using RSpec.
535
653
  // It SHOULD NOT be used for other purposes.
536
- static VALUE _native_sample(DDTRACE_UNUSED VALUE _self, VALUE collector_instance, VALUE profiler_overhead_stack_thread, VALUE allow_exception) {
654
+ static VALUE _native_sample(DDTRACE_UNUSED VALUE _self, VALUE collector_instance, VALUE allow_exception) {
537
655
  ENFORCE_BOOLEAN(allow_exception);
538
656
 
539
- if (!is_thread_alive(profiler_overhead_stack_thread)) raise_error(rb_eArgError, "Unexpected: profiler_overhead_stack_thread is not alive");
540
-
541
657
  if (allow_exception == Qfalse) debug_enter_unsafe_context();
542
658
 
543
- thread_context_collector_sample(collector_instance, monotonic_wall_time_now_ns(RAISE_ON_FAILURE), profiler_overhead_stack_thread);
659
+ thread_context_collector_sample(collector_instance, monotonic_wall_time_now_ns(RAISE_ON_FAILURE));
544
660
 
545
661
  if (allow_exception == Qfalse) debug_leave_unsafe_context();
546
662
 
@@ -583,6 +699,53 @@ static VALUE _native_sample_after_gc(DDTRACE_UNUSED VALUE self, VALUE collector_
583
699
  return Qtrue;
584
700
  }
585
701
 
702
+ // Record profiler sampling overhead as a placeholder stack
703
+ static void record_sampling_overhead(thread_context_collector_state *state, per_thread_context *current_thread_context) {
704
+ long wall_time_after_sampling = monotonic_wall_time_now_ns(RAISE_ON_FAILURE);
705
+ long cpu_time_after_sampling = cpu_time_now_ns(current_thread_context);
706
+
707
+ long overhead_cpu_time_ns = update_time_since_previous_sample(
708
+ &current_thread_context->cpu_time_at_previous_sample_ns,
709
+ cpu_time_after_sampling,
710
+ current_thread_context->gc_tracking.cpu_time_at_start_ns,
711
+ IS_CPU_TIME);
712
+
713
+ long overhead_wall_time_ns = update_time_since_previous_sample(
714
+ &current_thread_context->wall_time_at_previous_sample_ns,
715
+ wall_time_after_sampling,
716
+ INVALID_TIME,
717
+ IS_WALL_TIME);
718
+
719
+ ddog_prof_Label overhead_labels[] = {
720
+ {.key = DDOG_CHARSLICE_C("thread id"), .str = DDOG_CHARSLICE_C("0"), .num = 0},
721
+ {.key = DDOG_CHARSLICE_C("thread name"), .str = DDOG_CHARSLICE_C("Datadog::Profiling::Sampling"), .num = 0},
722
+ {.key = DDOG_CHARSLICE_C("state"), .str = DDOG_CHARSLICE_C("had cpu"), .num = 0},
723
+ {.key = DDOG_CHARSLICE_C("profiler overhead"), .num = 1},
724
+ };
725
+
726
+ int64_t end_timestamp_ns = monotonic_to_system_epoch_ns(&state->time_converter_state, wall_time_after_sampling);
727
+
728
+ ddog_prof_Location overhead_location = {
729
+ .mapping = {.filename = DDOG_CHARSLICE_C(""), .build_id = DDOG_CHARSLICE_C(""), .build_id_id = {}},
730
+ .function = {
731
+ .name = DDOG_CHARSLICE_C("sampling"),
732
+ .filename = char_slice_from_ruby_string(state->overhead_filename),
733
+ },
734
+ .line = 0,
735
+ };
736
+
737
+ record_sample(
738
+ state->recorder_instance,
739
+ (ddog_prof_Slice_Location) {.ptr = &overhead_location, .len = 1},
740
+ (sample_values) {.cpu_time_ns = overhead_cpu_time_ns, .cpu_or_wall_samples = 1, .wall_time_ns = overhead_wall_time_ns},
741
+ (sample_labels) {
742
+ .labels = (ddog_prof_Slice_Label) {.ptr = overhead_labels, .len = sizeof(overhead_labels) / sizeof(overhead_labels[0])},
743
+ .state_label = NULL,
744
+ .end_timestamp_ns = end_timestamp_ns,
745
+ }
746
+ );
747
+ }
748
+
586
749
  // This function gets called from the Collectors::CpuAndWallTimeWorker to trigger the actual sampling.
587
750
  //
588
751
  // Assumption 1: This function is called in a thread that is holding the Global VM Lock. Caller is responsible for enforcing this.
@@ -591,14 +754,12 @@ static VALUE _native_sample_after_gc(DDTRACE_UNUSED VALUE self, VALUE collector_
591
754
  // Assumption 4: This function IS NOT called in a reentrant way.
592
755
  // Assumption 5: This function is called from the main Ractor (if Ruby has support for Ractors).
593
756
  //
594
- // The `profiler_overhead_stack_thread` is used to attribute the profiler overhead to a stack borrowed from a different thread
595
- // (belonging to ddtrace), so that the overhead is visible in the profile rather than blamed on user code.
596
- void thread_context_collector_sample(VALUE self_instance, long current_monotonic_wall_time_ns, VALUE profiler_overhead_stack_thread) {
757
+ void thread_context_collector_sample(VALUE self_instance, long current_monotonic_wall_time_ns) {
597
758
  thread_context_collector_state *state;
598
759
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
599
760
 
600
761
  VALUE current_thread = rb_thread_current();
601
- per_thread_context *current_thread_context = get_or_create_context_for(current_thread, state);
762
+ per_thread_context *current_thread_context = get_or_create_context_for(current_thread);
602
763
  long cpu_time_at_sample_start_for_current_thread = cpu_time_now_ns(current_thread_context);
603
764
 
604
765
  VALUE threads = thread_list(state);
@@ -606,68 +767,59 @@ void thread_context_collector_sample(VALUE self_instance, long current_monotonic
606
767
  const long thread_count = RARRAY_LEN(threads);
607
768
  for (long i = 0; i < thread_count; i++) {
608
769
  VALUE thread = RARRAY_AREF(threads, i);
609
- per_thread_context *thread_context = get_or_create_context_for(thread, state);
770
+ per_thread_context *thread_context = get_or_create_context_for(thread);
610
771
 
611
- // We account for cpu-time for the current thread in a different way -- we use the cpu-time at sampling start, to avoid
612
- // blaming the time the profiler took on whatever's running on the thread right now
613
- long current_cpu_time_ns = thread != current_thread ? cpu_time_now_ns(thread_context) : cpu_time_at_sample_start_for_current_thread;
772
+ // We account for cpu-time for the current thread in a different way: we use the cpu-time at sampling start,
773
+ // to avoid blaming the time the profiler took on whatever is currently running on the thread,
774
+ // and instead we report that time the profiler took as sampling overhead below.
775
+ long current_cpu_time_ns = (thread == current_thread) ? cpu_time_at_sample_start_for_current_thread : cpu_time_now_ns(thread_context);
614
776
 
615
777
  update_metrics_and_sample(
616
778
  state,
617
- /* thread_being_sampled: */ thread,
618
- /* stack_from_thread: */ thread,
779
+ thread,
619
780
  thread_context,
620
- &thread_context->sampling_buffer,
621
781
  current_cpu_time_ns,
622
- current_monotonic_wall_time_ns
623
- );
782
+ current_monotonic_wall_time_ns,
783
+ false);
624
784
  }
625
785
 
626
- state->sample_count++;
786
+ state->stats.sample_count++;
627
787
 
628
- // TODO: This seems somewhat overkill and inefficient to do often; right now we just do it every few samples
629
- // but there's probably a better way to do this if we actually track when threads finish
630
- if (state->sample_count % 100 == 0) remove_context_for_dead_threads(state);
631
-
632
- update_metrics_and_sample(
633
- state,
634
- /* thread_being_sampled: */ current_thread,
635
- /* stack_from_thread: */ profiler_overhead_stack_thread,
636
- current_thread_context,
637
- // Here we use the overhead thread's sampling buffer so as to not invalidate the cache in the buffer of the thread being sampled
638
- &get_or_create_context_for(profiler_overhead_stack_thread, state)->sampling_buffer,
639
- cpu_time_now_ns(current_thread_context),
640
- monotonic_wall_time_now_ns(RAISE_ON_FAILURE)
641
- );
788
+ // If the current thread is a profiler-internal thread, we don't use record_sampling_overhead()
789
+ // and accept the sampling overhead is attributed to the profiler-internal thread
790
+ // (both are under the same datadog group in the flamegraph).
791
+ // The reason we need to do this is profiler-internal threads are skipped during per-tick sampling
792
+ // so their timestamps are not updated. record_sampling_overhead() would see the stale previous-sample timestamps and
793
+ // attribute the entire sleep interval incorrectly as overhead instead of just the sampling work.
794
+ if (!current_thread_context->is_profiler_internal_thread) {
795
+ record_sampling_overhead(state, current_thread_context);
796
+ }
642
797
  }
643
798
 
644
799
  static void update_metrics_and_sample(
645
800
  thread_context_collector_state *state,
646
801
  VALUE thread_being_sampled,
647
- VALUE stack_from_thread, // This can be different when attributing profiler overhead using a different stack
648
802
  per_thread_context *thread_context,
649
- sampling_buffer* sampling_buffer,
650
803
  long current_cpu_time_ns,
651
- long current_monotonic_wall_time_ns
804
+ long current_monotonic_wall_time_ns,
805
+ bool force_sample
652
806
  ) {
653
807
  bool is_gvl_waiting_state =
654
- handle_gvl_waiting(state, thread_being_sampled, stack_from_thread, thread_context, sampling_buffer, current_cpu_time_ns);
808
+ handle_gvl_waiting(state, thread_being_sampled, thread_context, current_cpu_time_ns);
809
+
810
+ if (skip_sample(state, thread_context, is_gvl_waiting_state, force_sample)) return;
655
811
 
656
812
  // Don't assign/update cpu during "Waiting for GVL"
657
813
  long cpu_time_elapsed_ns = is_gvl_waiting_state ? 0 : update_time_since_previous_sample(
658
814
  &thread_context->cpu_time_at_previous_sample_ns,
659
815
  current_cpu_time_ns,
660
816
  thread_context->gc_tracking.cpu_time_at_start_ns,
661
- IS_NOT_WALL_TIME
817
+ IS_CPU_TIME
662
818
  );
663
819
 
664
820
  long wall_time_elapsed_ns = update_time_since_previous_sample(
665
821
  &thread_context->wall_time_at_previous_sample_ns,
666
822
  current_monotonic_wall_time_ns,
667
- // We explicitly pass in `INVALID_TIME` as an argument for `gc_start_time_ns` here because we don't want wall-time
668
- // accounting to change during GC.
669
- // E.g. if 60 seconds pass in the real world, 60 seconds of wall-time are recorded, regardless of the thread doing
670
- // GC or not.
671
823
  INVALID_TIME,
672
824
  IS_WALL_TIME
673
825
  );
@@ -675,7 +827,7 @@ static void update_metrics_and_sample(
675
827
  // A thread enters "Waiting for GVL", well, as the name implies, without the GVL.
676
828
  //
677
829
  // As a consequence, it's possible that a thread enters "Waiting for GVL" in parallel with the current thread working
678
- // on sampling, and thus for the `current_monotonic_wall_time_ns` (which is recorded at the start of sampling)
830
+ // on sampling, and thus for the `current_monotonic_wall_time_ns` (which is recorded at the start of sampling)
679
831
  // to be < the time at which we started Waiting for GVL.
680
832
  //
681
833
  // All together, this means that when `handle_gvl_waiting` creates an extra sample (see comments on that function for
@@ -690,9 +842,7 @@ static void update_metrics_and_sample(
690
842
  trigger_sample_for_thread(
691
843
  state,
692
844
  thread_being_sampled,
693
- stack_from_thread,
694
845
  thread_context,
695
- sampling_buffer,
696
846
  (sample_values) {.cpu_time_ns = cpu_time_elapsed_ns, .cpu_or_wall_samples = 1, .wall_time_ns = wall_time_elapsed_ns},
697
847
  current_monotonic_wall_time_ns,
698
848
  NULL,
@@ -702,6 +852,37 @@ static void update_metrics_and_sample(
702
852
  );
703
853
  }
704
854
 
855
+ static bool skip_sample(thread_context_collector_state *state, per_thread_context *thread_context, bool is_gvl_waiting_state, bool force_sample) {
856
+ if (!force_sample && thread_context->is_profiler_internal_thread) {
857
+ state->stats.profiler_thread_samples_skipped++;
858
+ return true;
859
+ }
860
+
861
+ // Racy read but harmless, can only cause an extra sample
862
+ uint64_t gvl_state_change_count = thread_context->gvl_state_change_count;
863
+
864
+ // Skip this per-tick sample entirely when the thread does not have the GVL and did not acquire
865
+ // it since the previous sample: its Ruby-level stack has not changed. The skipped wall-time will
866
+ // be picked up by either by an extra sample when the thread acquires the GVL, or by
867
+ // the on-serialize flush in the stack recorder (using was_skipped_at_last_sample).
868
+ // The check is gated by `!is_gvl_waiting_state` so the existing "Waiting for GVL" machinery
869
+ // in handle_gvl_waiting (situation 1 extra sample, situation 2 regular sample) keeps running.
870
+ // TODO: we could probably also skip while "Waiting for GVL"
871
+ if (!is_gvl_waiting_state &&
872
+ !force_sample &&
873
+ (gvl_state_change_count & GVL_SUSPENDED) &&
874
+ gvl_state_change_count == thread_context->gvl_state_change_count_at_previous_sample) {
875
+ state->stats.inactive_thread_samples_skipped++;
876
+ thread_context->was_skipped_at_last_sample = true;
877
+ return true; // Do NOT update wall_time_at_previous_sample_ns or cpu_time_at_previous_sample_ns
878
+ } else {
879
+ // We are going to sample, update the state accordingly:
880
+ thread_context->gvl_state_change_count_at_previous_sample = gvl_state_change_count;
881
+ thread_context->was_skipped_at_last_sample = false;
882
+ return false;
883
+ }
884
+ }
885
+
705
886
  // This function gets called when Ruby is about to start running the Garbage Collector on the current thread.
706
887
  // It updates the per_thread_context of the current thread to include the current cpu/wall times, to be used to later
707
888
  // create an event including the cpu/wall time spent in garbage collector work.
@@ -715,15 +896,13 @@ static void update_metrics_and_sample(
715
896
  void thread_context_collector_on_gc_start(VALUE self_instance) {
716
897
  thread_context_collector_state *state;
717
898
  if (!rb_typeddata_is_kind_of(self_instance, &thread_context_collector_typed_data)) return;
718
- // This should never fail the the above check passes
899
+ // This should never fail when the above check passes
719
900
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
720
901
 
721
- per_thread_context *thread_context = get_context_for(rb_thread_current(), state);
902
+ per_thread_context *thread_context = get_per_thread_context(rb_thread_current());
722
903
 
723
- // If there was no previously-existing context for this thread, we won't allocate one (see safety). For now we just drop
724
- // the GC sample, under the assumption that "a thread that is so new that we never sampled it even once before it triggers
725
- // GC" is a rare enough case that we can just ignore it.
726
- // We can always improve this later if we find that this happens often (and we have the counter to help us figure that out)!
904
+ // Context is created eagerly via on_thread_begin_event, so this should not normally be NULL.
905
+ // We can't get_or_create_context_for() here since we can't allocate (GC context).
727
906
  if (thread_context == NULL) {
728
907
  state->stats.gc_samples_missed_due_to_missing_context++;
729
908
  return;
@@ -748,13 +927,12 @@ __attribute__((warn_unused_result))
748
927
  bool thread_context_collector_on_gc_finish(VALUE self_instance) {
749
928
  thread_context_collector_state *state;
750
929
  if (!rb_typeddata_is_kind_of(self_instance, &thread_context_collector_typed_data)) return false;
751
- // This should never fail the the above check passes
930
+ // This should never fail when the above check passes
752
931
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
753
932
 
754
- per_thread_context *thread_context = get_context_for(rb_thread_current(), state);
933
+ per_thread_context *thread_context = get_per_thread_context(rb_thread_current());
755
934
 
756
- // If there was no previously-existing context for this thread, we won't allocate one (see safety). We keep a metric for
757
- // how often this happens -- see on_gc_start.
935
+ // Context is created eagerly, so this should not normally be NULL (see on_gc_start).
758
936
  if (thread_context == NULL) return false;
759
937
 
760
938
  long cpu_time_at_start_ns = thread_context->gc_tracking.cpu_time_at_start_ns;
@@ -871,10 +1049,8 @@ VALUE thread_context_collector_sample_after_gc(VALUE self_instance) {
871
1049
 
872
1050
  static void trigger_sample_for_thread(
873
1051
  thread_context_collector_state *state,
874
- VALUE thread,
875
- VALUE stack_from_thread, // This can be different when attributing profiler overhead using a different stack
1052
+ VALUE thread_being_sampled,
876
1053
  per_thread_context *thread_context,
877
- sampling_buffer* sampling_buffer,
878
1054
  sample_values values,
879
1055
  long current_monotonic_wall_time_ns,
880
1056
  // These two labels are only used for allocation profiling; @ivoanjo: may want to refactor this at some point?
@@ -888,7 +1064,6 @@ static void trigger_sample_for_thread(
888
1064
  int max_label_count =
889
1065
  1 + // thread id
890
1066
  1 + // thread name
891
- 1 + // profiler overhead
892
1067
  2 + // ruby vm type and allocation class
893
1068
  1 + // state (only set for cpu/wall-time samples)
894
1069
  2; // local root span id and span id
@@ -900,13 +1075,13 @@ static void trigger_sample_for_thread(
900
1075
  .str = thread_context->thread_id_char_slice
901
1076
  };
902
1077
 
903
- VALUE thread_name = thread_name_for(thread);
1078
+ VALUE thread_name = thread_name_for(thread_being_sampled);
904
1079
  if (thread_name != Qnil) {
905
1080
  labels[label_pos++] = (ddog_prof_Label) {
906
1081
  .key = DDOG_CHARSLICE_C("thread name"),
907
1082
  .str = char_slice_from_ruby_string(thread_name)
908
1083
  };
909
- } else if (thread == state->main_thread) { // Threads are often not named, but we can have a nice fallback for this special thread
1084
+ } else if (thread_being_sampled == state->main_thread) { // Threads are often not named, but we can have a nice fallback for this special thread
910
1085
  ddog_CharSlice main_thread_name = DDOG_CHARSLICE_C("main");
911
1086
  labels[label_pos++] = (ddog_prof_Label) {
912
1087
  .key = DDOG_CHARSLICE_C("thread name"),
@@ -922,11 +1097,11 @@ static void trigger_sample_for_thread(
922
1097
  }
923
1098
 
924
1099
  trace_identifiers trace_identifiers_result = {.valid = false, .trace_endpoint = Qnil};
925
- trace_identifiers_for(state, thread, &trace_identifiers_result, is_safe_to_allocate_objects);
1100
+ trace_identifiers_for(state, thread_being_sampled, &trace_identifiers_result, is_safe_to_allocate_objects);
926
1101
 
927
1102
  if (!trace_identifiers_result.valid && state->otel_context_enabled != OTEL_CONTEXT_ENABLED_FALSE) {
928
1103
  // If we couldn't get something with ddtrace, let's see if we can get some trace identifiers from opentelemetry directly
929
- otel_without_ddtrace_trace_identifiers_for(state, thread, &trace_identifiers_result, is_safe_to_allocate_objects);
1104
+ otel_without_ddtrace_trace_identifiers_for(state, thread_being_sampled, &trace_identifiers_result, is_safe_to_allocate_objects);
930
1105
  }
931
1106
 
932
1107
  if (trace_identifiers_result.valid) {
@@ -951,13 +1126,6 @@ static void trigger_sample_for_thread(
951
1126
  }
952
1127
  }
953
1128
 
954
- if (thread != stack_from_thread) {
955
- labels[label_pos++] = (ddog_prof_Label) {
956
- .key = DDOG_CHARSLICE_C("profiler overhead"),
957
- .num = 1
958
- };
959
- }
960
-
961
1129
  if (ruby_vm_type != NULL) {
962
1130
  labels[label_pos++] = (ddog_prof_Label) {
963
1131
  .key = DDOG_CHARSLICE_C("ruby vm type"),
@@ -1003,8 +1171,9 @@ static void trigger_sample_for_thread(
1003
1171
  }
1004
1172
 
1005
1173
  sample_thread(
1006
- stack_from_thread,
1007
- sampling_buffer,
1174
+ thread_being_sampled,
1175
+ &thread_context->sampling_buffer,
1176
+ state->locations,
1008
1177
  state->recorder_instance,
1009
1178
  values,
1010
1179
  (sample_labels) {
@@ -1032,32 +1201,38 @@ static VALUE _native_thread_list(DDTRACE_UNUSED VALUE _self) {
1032
1201
  return result;
1033
1202
  }
1034
1203
 
1035
- static per_thread_context *get_or_create_context_for(VALUE thread, thread_context_collector_state *state) {
1036
- per_thread_context* thread_context = NULL;
1037
- st_data_t value_context = 0;
1038
-
1039
- if (st_lookup(state->hash_map_per_thread_context, (st_data_t) thread, &value_context)) {
1040
- thread_context = (per_thread_context*) value_context;
1041
- } else {
1042
- thread_context = calloc(1, sizeof(per_thread_context)); // See "note on calloc vs ruby_xcalloc use" in heap_recorder.c
1043
- initialize_context(thread, thread_context, state);
1044
- st_insert(state->hash_map_per_thread_context, (st_data_t) thread, (st_data_t) thread_context);
1204
+ static void check_frozen_thread(VALUE thread) {
1205
+ if (RB_OBJ_FROZEN(thread)) {
1206
+ raise_error(rb_eFrozenError, "Cannot setup profiler state for Thread %"PRIsVALUE" because it is frozen. Please avoid freezing Thread instances and/or report the issue to dd-trace-rb", thread);
1045
1207
  }
1046
-
1047
- return thread_context;
1048
1208
  }
1049
1209
 
1050
- static per_thread_context *get_context_for(VALUE thread, thread_context_collector_state *state) {
1051
- per_thread_context* thread_context = NULL;
1052
- st_data_t value_context = 0;
1210
+ // See the docs on struct per_thread_context.
1211
+ // This allocates a Ruby object and therefore needs the GVL and is not safe to call from RUBY_INTERNAL_EVENT_* hooks.
1212
+ static per_thread_context *get_or_create_context_for(VALUE thread) {
1213
+ per_thread_context *thread_context = get_per_thread_context(thread);
1214
+ if (thread_context != NULL) return thread_context;
1053
1215
 
1054
- if (st_lookup(state->hash_map_per_thread_context, (st_data_t) thread, &value_context)) {
1055
- thread_context = (per_thread_context*) value_context;
1056
- }
1216
+ check_frozen_thread(thread);
1217
+
1218
+ thread_context = calloc(1, sizeof(per_thread_context)); // See "note on calloc vs ruby_xcalloc use" in heap_recorder.c
1219
+ initialize_context(thread, thread_context);
1057
1220
 
1221
+ VALUE wrapper = TypedData_Wrap_Struct(rb_cObject, &per_thread_context_typed_data, thread_context);
1222
+ rb_ivar_set(thread, dd_per_thread_context_id, wrapper);
1223
+
1224
+ set_per_thread_context(thread, thread_context);
1058
1225
  return thread_context;
1059
1226
  }
1060
1227
 
1228
+ static void on_thread_begin_event(VALUE tracepoint_data, DDTRACE_UNUSED void *unused) {
1229
+ if (!ddtrace_rb_ractor_main_p()) return;
1230
+
1231
+ VALUE thread = rb_tracearg_self(rb_tracearg_from_tracepoint(tracepoint_data));
1232
+ ENFORCE_THREAD(thread);
1233
+ get_or_create_context_for(thread);
1234
+ }
1235
+
1061
1236
  #define LOGGING_GEM_PATH "/lib/logging/diagnostic_context.rb"
1062
1237
 
1063
1238
  // The `logging` gem monkey patches thread creation, which makes the `invoke_location_for` useless, since every thread
@@ -1079,8 +1254,12 @@ static bool is_logging_gem_monkey_patch(VALUE invoke_file_location) {
1079
1254
  return strncmp(invoke_file + invoke_file_len - logging_gem_path_len, LOGGING_GEM_PATH, logging_gem_path_len) == 0;
1080
1255
  }
1081
1256
 
1082
- static void initialize_context(VALUE thread, per_thread_context *thread_context, thread_context_collector_state *state) {
1083
- sampling_buffer_initialize(&thread_context->sampling_buffer, state->max_frames, state->locations);
1257
+ static void initialize_context(VALUE thread, per_thread_context *thread_context) {
1258
+ // We always create per_thread_context's with latest_max_frames because
1259
+ // 1) we don't always have access to the ThreadContext object (e.g. in a TracePoint).
1260
+ // 2) the per_thread_context's are global and so might not match the ThreadContext#max_frames anyway.
1261
+ // This is fine because sample_thread() handles when they don't match and resizes as needed.
1262
+ sampling_buffer_initialize(&thread_context->sampling_buffer, latest_max_frames);
1084
1263
 
1085
1264
  snprintf(thread_context->thread_id, THREAD_ID_LIMIT_CHARS, "%"PRIu64" (%lu)", native_thread_id_for(thread), (unsigned long) thread_id_for(thread));
1086
1265
  thread_context->thread_id_char_slice = (ddog_CharSlice) {.ptr = thread_context->thread_id, .len = strlen(thread_context->thread_id)};
@@ -1099,7 +1278,7 @@ static void initialize_context(VALUE thread, per_thread_context *thread_context,
1099
1278
  } else {
1100
1279
  snprintf(thread_context->thread_invoke_location, THREAD_INVOKE_LOCATION_LIMIT_CHARS, "%s", "(Unnamed thread)");
1101
1280
  }
1102
- } else if (thread != state->main_thread) {
1281
+ } else if (thread != rb_thread_main()) {
1103
1282
  // If the first function of a thread is native code, there won't be an invoke location, so we use this fallback.
1104
1283
  // NOTE: In the future, I wonder if we could take the pointer to the native function, and try to see if there's a native
1105
1284
  // symbol attached to it.
@@ -1113,32 +1292,15 @@ static void initialize_context(VALUE thread, per_thread_context *thread_context,
1113
1292
 
1114
1293
  thread_context->thread_cpu_time_id = thread_cpu_time_id_for(thread);
1115
1294
 
1116
- // These will get initialized during actual sampling
1117
- thread_context->cpu_time_at_previous_sample_ns = INVALID_TIME;
1118
- thread_context->wall_time_at_previous_sample_ns = INVALID_TIME;
1295
+ thread_context->wall_time_at_previous_sample_ns = monotonic_wall_time_now_ns(RAISE_ON_FAILURE);
1296
+ thread_context->cpu_time_at_previous_sample_ns = cpu_time_now_ns(thread_context);
1119
1297
 
1120
1298
  // These will only be used during a GC operation
1121
1299
  thread_context->gc_tracking.cpu_time_at_start_ns = INVALID_TIME;
1122
1300
  thread_context->gc_tracking.wall_time_at_start_ns = INVALID_TIME;
1123
1301
 
1124
- #ifndef NO_GVL_INSTRUMENTATION
1125
- // We use this special location to store data that can be accessed without any
1126
- // kind of synchronization (e.g. by threads without the GVL).
1127
- //
1128
- // We set this marker here for two purposes:
1129
- // * To make sure there's no stale data from a previous execution of the profiler.
1130
- // * To mark threads that are actually being profiled
1131
- //
1132
- // (Setting this is potentially a race, but what we want is to avoid _stale_ data, so
1133
- // if this gets set concurrently with context initialization, then such a value will belong
1134
- // to the current profiler instance, so that's OK)
1135
- gvl_profiling_state_thread_object_set(thread, GVL_WAITING_ENABLED_EMPTY);
1136
- #endif
1137
- }
1138
-
1139
- static void free_context(per_thread_context* thread_context) {
1140
- sampling_buffer_free(&thread_context->sampling_buffer);
1141
- free(thread_context); // See "note on calloc vs ruby_xcalloc use" in heap_recorder.c
1302
+ thread_context->gvl_waiting_at = 0;
1303
+ thread_context->gvl_state_change_count = 0;
1142
1304
  }
1143
1305
 
1144
1306
  static VALUE _native_inspect(DDTRACE_UNUSED VALUE _self, VALUE collector_instance) {
@@ -1148,13 +1310,11 @@ static VALUE _native_inspect(DDTRACE_UNUSED VALUE _self, VALUE collector_instanc
1148
1310
  VALUE result = rb_str_new2(" (native state)");
1149
1311
 
1150
1312
  // Update this when modifying state struct
1151
- rb_str_concat(result, rb_sprintf(" max_frames=%d", state->max_frames));
1152
- rb_str_concat(result, rb_sprintf(" hash_map_per_thread_context=%"PRIsVALUE, per_thread_context_st_table_as_ruby_hash(state)));
1313
+ rb_str_concat(result, rb_sprintf(" max_frames=%d", state->locations.len));
1153
1314
  rb_str_concat(result, rb_sprintf(" recorder_instance=%"PRIsVALUE, state->recorder_instance));
1154
1315
  VALUE tracer_context_key = state->tracer_context_key == MISSING_TRACER_CONTEXT_KEY ? Qnil : ID2SYM(state->tracer_context_key);
1155
1316
  rb_str_concat(result, rb_sprintf(" tracer_context_key=%+"PRIsVALUE, tracer_context_key));
1156
- rb_str_concat(result, rb_sprintf(" sample_count=%u", state->sample_count));
1157
- rb_str_concat(result, rb_sprintf(" stats=%"PRIsVALUE, stats_as_ruby_hash(state)));
1317
+ rb_str_concat(result, rb_sprintf(" stats=%"PRIsVALUE, stats_to_ruby_hash(state, rb_hash_new())));
1158
1318
  rb_str_concat(result, rb_sprintf(" endpoint_collection_enabled=%"PRIsVALUE, state->endpoint_collection_enabled ? Qtrue : Qfalse));
1159
1319
  rb_str_concat(result, rb_sprintf(" native_filenames_enabled=%"PRIsVALUE, state->native_filenames_enabled ? Qtrue : Qfalse));
1160
1320
  // Note: `st_table_size()` is available from Ruby 3.2+ but not before
@@ -1168,23 +1328,13 @@ static VALUE _native_inspect(DDTRACE_UNUSED VALUE _self, VALUE collector_instanc
1168
1328
  rb_str_concat(result, rb_sprintf(" main_thread=%"PRIsVALUE, state->main_thread));
1169
1329
  rb_str_concat(result, rb_sprintf(" gc_tracking=%"PRIsVALUE, gc_tracking_as_ruby_hash(state)));
1170
1330
  rb_str_concat(result, rb_sprintf(" otel_current_span_key=%"PRIsVALUE, state->otel_current_span_key));
1171
- rb_str_concat(result, rb_sprintf(" global_waiting_for_gvl_threshold_ns=%u", global_waiting_for_gvl_threshold_ns));
1331
+ rb_str_concat(result, rb_sprintf(" waiting_for_gvl_threshold_ns=%u", state->waiting_for_gvl_threshold_ns));
1172
1332
 
1173
1333
  return result;
1174
1334
  }
1175
1335
 
1176
- static VALUE per_thread_context_st_table_as_ruby_hash(thread_context_collector_state *state) {
1177
- VALUE result = rb_hash_new();
1178
- st_foreach(state->hash_map_per_thread_context, per_thread_context_as_ruby_hash, result);
1179
- return result;
1180
- }
1181
-
1182
- static int per_thread_context_as_ruby_hash(st_data_t key_thread, st_data_t value_context, st_data_t result_hash) {
1183
- VALUE thread = (VALUE) key_thread;
1184
- per_thread_context *thread_context = (per_thread_context*) value_context;
1185
- VALUE result = (VALUE) result_hash;
1336
+ static VALUE per_thread_context_to_ruby_hash(per_thread_context *thread_context) {
1186
1337
  VALUE context_as_hash = rb_hash_new();
1187
- rb_hash_aset(result, thread, context_as_hash);
1188
1338
 
1189
1339
  VALUE arguments[] = {
1190
1340
  ID2SYM(rb_intern("thread_id")), /* => */ rb_str_new2(thread_context->thread_id),
@@ -1201,24 +1351,28 @@ static int per_thread_context_as_ruby_hash(st_data_t key_thread, st_data_t value
1201
1351
  ID2SYM(rb_intern("gc_tracking.cpu_time_at_start_ns")), /* => */ LONG2NUM(thread_context->gc_tracking.cpu_time_at_start_ns),
1202
1352
  ID2SYM(rb_intern("gc_tracking.wall_time_at_start_ns")), /* => */ LONG2NUM(thread_context->gc_tracking.wall_time_at_start_ns),
1203
1353
 
1204
- #ifndef NO_GVL_INSTRUMENTATION
1205
- ID2SYM(rb_intern("gvl_waiting_at")), /* => */ LONG2NUM(gvl_profiling_state_thread_object_get(thread)),
1206
- #endif
1354
+ ID2SYM(rb_intern("gvl_waiting_at")), /* => */ LONG2NUM(thread_context->gvl_waiting_at),
1355
+ ID2SYM(rb_intern("gvl_state_change_count")), /* => */ ULL2NUM(thread_context->gvl_state_change_count),
1356
+ ID2SYM(rb_intern("gvl_state_change_count_at_previous_sample")), /* => */ ULL2NUM(thread_context->gvl_state_change_count_at_previous_sample),
1357
+ ID2SYM(rb_intern("was_skipped_at_last_sample")), /* => */ thread_context->was_skipped_at_last_sample ? Qtrue : Qfalse,
1358
+ ID2SYM(rb_intern("is_profiler_internal_thread")), /* => */ thread_context->is_profiler_internal_thread ? Qtrue : Qfalse,
1207
1359
  };
1208
1360
  for (long unsigned int i = 0; i < VALUE_COUNT(arguments); i += 2) rb_hash_aset(context_as_hash, arguments[i], arguments[i+1]);
1209
1361
 
1210
- return ST_CONTINUE;
1362
+ return context_as_hash;
1211
1363
  }
1212
1364
 
1213
- static VALUE stats_as_ruby_hash(thread_context_collector_state *state) {
1365
+ static VALUE stats_to_ruby_hash(thread_context_collector_state *state, VALUE hash) {
1214
1366
  // Update this when modifying state struct (stats inner struct)
1215
- VALUE stats_as_hash = rb_hash_new();
1216
1367
  VALUE arguments[] = {
1368
+ ID2SYM(rb_intern("sample_count")), /* => */ UINT2NUM(state->stats.sample_count),
1217
1369
  ID2SYM(rb_intern("gc_samples")), /* => */ UINT2NUM(state->stats.gc_samples),
1218
1370
  ID2SYM(rb_intern("gc_samples_missed_due_to_missing_context")), /* => */ UINT2NUM(state->stats.gc_samples_missed_due_to_missing_context),
1371
+ ID2SYM(rb_intern("inactive_thread_samples_skipped")), /* => */ UINT2NUM(state->stats.inactive_thread_samples_skipped),
1372
+ ID2SYM(rb_intern("profiler_thread_samples_skipped")), /* => */ UINT2NUM(state->stats.profiler_thread_samples_skipped),
1219
1373
  };
1220
- for (long unsigned int i = 0; i < VALUE_COUNT(arguments); i += 2) rb_hash_aset(stats_as_hash, arguments[i], arguments[i+1]);
1221
- return stats_as_hash;
1374
+ for (long unsigned int i = 0; i < VALUE_COUNT(arguments); i += 2) rb_hash_aset(hash, arguments[i], arguments[i+1]);
1375
+ return hash;
1222
1376
  }
1223
1377
 
1224
1378
  static VALUE gc_tracking_as_ruby_hash(thread_context_collector_state *state) {
@@ -1234,36 +1388,35 @@ static VALUE gc_tracking_as_ruby_hash(thread_context_collector_state *state) {
1234
1388
  return result;
1235
1389
  }
1236
1390
 
1237
- static void remove_context_for_dead_threads(thread_context_collector_state *state) {
1238
- st_foreach(state->hash_map_per_thread_context, remove_if_dead_thread, 0 /* unused */);
1239
- }
1240
-
1241
- static int remove_if_dead_thread(st_data_t key_thread, st_data_t value_context, DDTRACE_UNUSED st_data_t _argument) {
1242
- VALUE thread = (VALUE) key_thread;
1243
- per_thread_context* thread_context = (per_thread_context*) value_context;
1244
-
1245
- if (is_thread_alive(thread)) return ST_CONTINUE;
1246
-
1247
- free_context(thread_context);
1248
- return ST_DELETE;
1249
- }
1250
-
1251
1391
  // This method exists only to enable testing Datadog::Profiling::Collectors::ThreadContext behavior using RSpec.
1252
1392
  // It SHOULD NOT be used for other purposes.
1253
1393
  //
1254
- // Returns the whole contents of the per_thread_context structs being tracked.
1394
+ // Returns the whole contents of the per_thread_context structs being tracked, by iterating all live threads.
1255
1395
  static VALUE _native_per_thread_context(DDTRACE_UNUSED VALUE _self, VALUE collector_instance) {
1256
1396
  thread_context_collector_state *state;
1257
1397
  TypedData_Get_Struct(collector_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1258
1398
 
1259
- return per_thread_context_st_table_as_ruby_hash(state);
1399
+ VALUE result = rb_hash_new();
1400
+ VALUE threads = thread_list(state);
1401
+ const long thread_count = RARRAY_LEN(threads);
1402
+ for (long i = 0; i < thread_count; i++) {
1403
+ VALUE thread = RARRAY_AREF(threads, i);
1404
+ per_thread_context *thread_context = get_per_thread_context(thread);
1405
+ if (thread_context != NULL) {
1406
+ rb_hash_aset(result, thread, per_thread_context_to_ruby_hash(thread_context));
1407
+ }
1408
+ }
1409
+ return result;
1260
1410
  }
1261
1411
 
1412
+ // gc_start_time_ns should only be passed if IS_CPU_TIME
1262
1413
  static long update_time_since_previous_sample(long *time_at_previous_sample_ns, long current_time_ns, long gc_start_time_ns, bool is_wall_time) {
1263
1414
  // If we didn't have a time for the previous sample, we use the current one
1264
1415
  if (*time_at_previous_sample_ns == INVALID_TIME) *time_at_previous_sample_ns = current_time_ns;
1265
1416
 
1266
- bool is_thread_doing_gc = gc_start_time_ns != INVALID_TIME;
1417
+ // We don't want wall-time accounting to change during GC.
1418
+ // E.g. if 60 seconds pass in the real world, 60 seconds of wall-time are recorded, regardless of the thread doing GC or not.
1419
+ bool is_thread_doing_gc = !is_wall_time && gc_start_time_ns != INVALID_TIME;
1267
1420
  long elapsed_time_ns = -1;
1268
1421
 
1269
1422
  if (is_thread_doing_gc) {
@@ -1350,7 +1503,7 @@ static VALUE _native_stats(DDTRACE_UNUSED VALUE _self, VALUE collector_instance)
1350
1503
  thread_context_collector_state *state;
1351
1504
  TypedData_Get_Struct(collector_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1352
1505
 
1353
- return stats_as_ruby_hash(state);
1506
+ return stats_to_ruby_hash(state, rb_hash_new());
1354
1507
  }
1355
1508
 
1356
1509
  // This method exists only to enable testing Datadog::Profiling::Collectors::ThreadContext behavior using RSpec.
@@ -1445,17 +1598,18 @@ static bool should_collect_resource(VALUE root_span) {
1445
1598
  //
1446
1599
  // Assumption: This method gets called BEFORE restarting profiling -- e.g. there are no components attempting to
1447
1600
  // trigger samples at the same time.
1601
+ //
1602
+ // Note that tests call this method directly in the same process without forking,
1603
+ // and in such a case non-current Threads keep running.
1448
1604
  static VALUE _native_reset_after_fork(DDTRACE_UNUSED VALUE self, VALUE collector_instance) {
1449
1605
  thread_context_collector_state *state;
1450
1606
  TypedData_Get_Struct(collector_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1451
1607
 
1452
- // Release all context memory before clearing the existing context
1453
- st_foreach(state->hash_map_per_thread_context, hash_map_per_thread_context_free_values, 0 /* unused */);
1454
-
1455
- st_clear(state->hash_map_per_thread_context);
1456
-
1457
1608
  state->stats = (struct stats) {}; // Resets all stats back to zero
1458
1609
 
1610
+ // Clear any leftover state from parent process in the current thread; all other threads are assumed dead
1611
+ _native_clear_per_thread_context_for(Qnil, rb_thread_current());
1612
+
1459
1613
  rb_funcall(state->recorder_instance, rb_intern("reset_after_fork"), 0);
1460
1614
 
1461
1615
  return Qtrue;
@@ -1475,14 +1629,9 @@ static VALUE thread_list(thread_context_collector_state *state) {
1475
1629
  // expected to be called from a signal handler and to be async-signal-safe.
1476
1630
  //
1477
1631
  // Also, no allocation (Ruby or malloc) can happen.
1478
- bool thread_context_collector_prepare_sample_inside_signal_handler(VALUE self_instance) {
1479
- thread_context_collector_state *state;
1480
- if (!rb_typeddata_is_kind_of(self_instance, &thread_context_collector_typed_data)) return false;
1481
- // This should never fail if the above check passes
1482
- TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1483
-
1632
+ bool thread_context_collector_prepare_sample_inside_signal_handler(void) {
1484
1633
  VALUE current_thread = rb_thread_current();
1485
- per_thread_context *thread_context = get_context_for(current_thread, state);
1634
+ per_thread_context *thread_context = get_per_thread_context(current_thread);
1486
1635
  if (thread_context == NULL) return false;
1487
1636
 
1488
1637
  return prepare_sample_thread(current_thread, &thread_context->sampling_buffer);
@@ -1493,12 +1642,12 @@ bool thread_context_collector_prepare_sample_inside_signal_handler(VALUE self_in
1493
1642
  //
1494
1643
  // Returns true if the after_allocation needs to be called (to do work that can't be done from inside the
1495
1644
  // tracepoint, such as allocate new objects), and false if it doesn't
1496
- bool thread_context_collector_sample_allocation(VALUE self_instance, unsigned int sample_weight, VALUE new_object) {
1645
+ //
1646
+ // The callers must ensure thread_context is non-NULL.
1647
+ bool thread_context_collector_sample_allocation(VALUE self_instance, per_thread_context *thread_context, unsigned int sample_weight, VALUE new_object) {
1497
1648
  thread_context_collector_state *state;
1498
1649
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1499
1650
 
1500
- VALUE current_thread = rb_thread_current();
1501
-
1502
1651
  enum ruby_value_type type = rb_type(new_object);
1503
1652
 
1504
1653
  // Tag samples with the VM internal types
@@ -1565,18 +1714,16 @@ bool thread_context_collector_sample_allocation(VALUE self_instance, unsigned in
1565
1714
 
1566
1715
  bool needs_after_allocation = track_object(state->recorder_instance, new_object, sample_weight, class_name);
1567
1716
 
1568
- per_thread_context *thread_context = get_or_create_context_for(current_thread, state);
1717
+ VALUE current_thread = rb_thread_current();
1569
1718
 
1570
1719
  trigger_sample_for_thread(
1571
1720
  state,
1572
- /* thread: */ current_thread,
1573
- /* stack_from_thread: */ current_thread,
1721
+ current_thread,
1574
1722
  thread_context,
1575
- &thread_context->sampling_buffer,
1576
1723
  (sample_values) {.alloc_samples = sample_weight, .alloc_samples_unscaled = 1, .heap_sample = true},
1577
1724
  INVALID_TIME, // For now we're not collecting timestamps for allocation events, as per profiling team internal discussions
1578
1725
  &ruby_vm_type,
1579
- &class_name,
1726
+ &class_name,
1580
1727
  /* is_gvl_waiting_state: */ false,
1581
1728
  /* is_safe_to_allocate_objects: */ false // Not safe to allocate further inside the NEWOBJ tracepoint
1582
1729
  );
@@ -1587,9 +1734,14 @@ bool thread_context_collector_sample_allocation(VALUE self_instance, unsigned in
1587
1734
  // This method exists only to enable testing Datadog::Profiling::Collectors::ThreadContext behavior using RSpec.
1588
1735
  // It SHOULD NOT be used for other purposes.
1589
1736
  static VALUE _native_sample_allocation(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE sample_weight, VALUE new_object) {
1737
+ per_thread_context *thread_context = get_per_thread_context(rb_thread_current());
1738
+ if (thread_context == NULL) {
1739
+ rb_raise(rb_eRuntimeError, "Missing per_thread_context for current thread in _native_sample_allocation");
1740
+ }
1741
+
1590
1742
  debug_enter_unsafe_context();
1591
1743
 
1592
- bool needs_after_allocation = thread_context_collector_sample_allocation(collector_instance, NUM2UINT(sample_weight), new_object);
1744
+ bool needs_after_allocation = thread_context_collector_sample_allocation(collector_instance, thread_context, NUM2UINT(sample_weight), new_object);
1593
1745
 
1594
1746
  debug_leave_unsafe_context();
1595
1747
 
@@ -1598,7 +1750,10 @@ static VALUE _native_sample_allocation(DDTRACE_UNUSED VALUE self, VALUE collecto
1598
1750
  return needs_after_allocation ? Qtrue : Qfalse;
1599
1751
  }
1600
1752
 
1601
- static VALUE new_empty_thread_inner(DDTRACE_UNUSED void *arg) { return Qnil; }
1753
+ static VALUE new_empty_thread_inner(DDTRACE_UNUSED void *arg) {
1754
+ rb_thread_sleep(INT_MAX);
1755
+ return Qnil;
1756
+ }
1602
1757
 
1603
1758
  // This method exists only to enable testing Datadog::Profiling::Collectors::ThreadContext behavior using RSpec.
1604
1759
  // It SHOULD NOT be used for other purposes.
@@ -1891,36 +2046,116 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1891
2046
  ((uint64_t)span_bytes[7]);
1892
2047
  }
1893
2048
 
2049
+ void thread_context_collector_stats(VALUE self_instance, VALUE stats_hash) {
2050
+ thread_context_collector_state *state;
2051
+ TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2052
+ stats_to_ruby_hash(state, stats_hash);
2053
+ }
2054
+
2055
+ void thread_context_collector_stats_reset_not_thread_safe(VALUE self_instance) {
2056
+ thread_context_collector_state *state;
2057
+ TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2058
+ state->stats = (struct stats) {};
2059
+ }
2060
+
2061
+ static void mark_thread_as_profiler_internal(per_thread_context *ctx) {
2062
+ ctx->is_profiler_internal_thread = true;
2063
+ }
2064
+
2065
+ void thread_context_collector_profiler_internal_thread_started(void) {
2066
+ per_thread_context *ctx = get_or_create_context_for(rb_thread_current());
2067
+ mark_thread_as_profiler_internal(ctx);
2068
+ }
2069
+
2070
+ static VALUE _native_mark_thread_as_profiler_internal(DDTRACE_UNUSED VALUE self, VALUE thread) {
2071
+ per_thread_context *ctx = get_or_create_context_for(thread);
2072
+ mark_thread_as_profiler_internal(ctx);
2073
+ return Qnil;
2074
+ }
2075
+
2076
+ // Called via rb_ensure when a profiler-internal thread (worker or idle helper) is about to exit.
2077
+ // Records a final sample so the thread's accumulated cpu/wall time since the last on_serialize
2078
+ // flush is not lost. on_serialize (below) also flushes profiler-internal threads during periodic
2079
+ // serialization, but it can't help at shutdown: by the time the final serialize runs, these
2080
+ // threads are already dead and absent from thread_list.
2081
+ void thread_context_collector_profiler_internal_thread_done(VALUE self_instance) {
2082
+ thread_context_collector_state *state;
2083
+ TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2084
+
2085
+ VALUE current_thread = rb_thread_current();
2086
+ per_thread_context *thread_context = get_or_create_context_for(current_thread);
2087
+ if (!thread_context->is_profiler_internal_thread) {
2088
+ rb_raise(rb_eRuntimeError, "current thread %"PRIsVALUE" is not profiler-internal thread", current_thread);
2089
+ }
2090
+
2091
+ long current_cpu_time_ns = cpu_time_now_ns(thread_context);
2092
+ long current_monotonic_wall_time_ns = monotonic_wall_time_now_ns(RAISE_ON_FAILURE);
2093
+
2094
+ update_metrics_and_sample(
2095
+ state,
2096
+ current_thread,
2097
+ thread_context,
2098
+ current_cpu_time_ns,
2099
+ current_monotonic_wall_time_ns,
2100
+ true);
2101
+ }
2102
+
2103
+ // Flushes threads whose last per-tick sample was skipped (either by the SUSPENDED-skip
2104
+ // optimization, or by is_profiler_internal_thread) so their accumulated time is recorded.
2105
+ // Called by the stack recorder at the start of _native_serialize (regular periodic flush).
2106
+ void thread_context_collector_on_serialize(VALUE self_instance) {
2107
+ thread_context_collector_state *state;
2108
+ TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2109
+
2110
+ long current_monotonic_wall_time_ns = monotonic_wall_time_now_ns(RAISE_ON_FAILURE);
2111
+ VALUE threads = thread_list(state);
2112
+ const long thread_count = RARRAY_LEN(threads);
2113
+
2114
+ for (long i = 0; i < thread_count; i++) {
2115
+ VALUE thread = RARRAY_AREF(threads, i);
2116
+ per_thread_context *thread_context = get_per_thread_context(thread);
2117
+
2118
+ if (thread_context != NULL && (thread_context->was_skipped_at_last_sample || thread_context->is_profiler_internal_thread)) {
2119
+ long current_cpu_time_ns = cpu_time_now_ns(thread_context);
2120
+ // We need to force_sample=true otherwise this sample would be skipped too
2121
+ update_metrics_and_sample(
2122
+ state,
2123
+ thread,
2124
+ thread_context,
2125
+ current_cpu_time_ns,
2126
+ current_monotonic_wall_time_ns,
2127
+ true);
2128
+ }
2129
+ }
2130
+ }
2131
+
1894
2132
  #ifndef NO_GVL_INSTRUMENTATION
1895
- // This function can get called from outside the GVL and even on non-main Ractors
1896
- void thread_context_collector_on_gvl_waiting(gvl_profiling_thread thread) {
1897
- // Because this function gets called from a thread that is NOT holding the GVL, we avoid touching the
1898
- // per-thread context directly.
1899
- //
1900
- // Instead, we ask Ruby to hold the data we need in Ruby's own special per-thread context area
1901
- // that's thread-safe and built for this kind of use
1902
- //
1903
- // Also, this function can get called on the non-main Ractor. We deal with this by checking if the value in the context
1904
- // is non-zero, since only `initialize_context` ever sets the value from 0 to non-zero for threads it sees.
1905
- intptr_t thread_being_profiled = gvl_profiling_state_get(thread);
1906
- if (!thread_being_profiled) return;
2133
+ void thread_context_collector_on_gvl_released(per_thread_context *thread_context) {
2134
+ thread_context->gvl_state_change_count |= GVL_SUSPENDED;
2135
+ }
1907
2136
 
2137
+ void thread_context_collector_on_gvl_waiting(per_thread_context *thread_context) {
1908
2138
  long current_monotonic_wall_time_ns = monotonic_wall_time_now_ns(DO_NOT_RAISE_ON_FAILURE);
1909
- if (current_monotonic_wall_time_ns <= 0 || current_monotonic_wall_time_ns > GVL_WAITING_ENABLED_EMPTY) return;
2139
+ if (current_monotonic_wall_time_ns <= 0) return;
1910
2140
 
1911
- gvl_profiling_state_set(thread, current_monotonic_wall_time_ns);
2141
+ thread_context->gvl_waiting_at = current_monotonic_wall_time_ns;
1912
2142
  }
1913
2143
 
1914
- // This function can get called from outside the GVL and even on non-main Ractors
2144
+ // This function runs on the passed thread and has the GVL because it gets called just after the Ruby thread acquired the GVL
1915
2145
  __attribute__((warn_unused_result))
1916
- on_gvl_running_result thread_context_collector_on_gvl_running_with_threshold(gvl_profiling_thread thread, uint32_t waiting_for_gvl_threshold_ns) {
1917
- intptr_t gvl_waiting_at = gvl_profiling_state_get(thread);
2146
+ on_gvl_running_result thread_context_collector_on_gvl_running(VALUE self_instance, VALUE thread, per_thread_context *thread_context) {
2147
+ thread_context_collector_state *state;
2148
+ TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2149
+
2150
+ // Bump the event counter and clears the state bit to "running"
2151
+ uint64_t counter_portion = thread_context->gvl_state_change_count >> 1;
2152
+ thread_context->gvl_state_change_count = ((counter_portion + 1) << 1) | GVL_RUNNING;
1918
2153
 
1919
- // Thread was not being profiled / not waiting on gvl
1920
- if (gvl_waiting_at == 0 || gvl_waiting_at == GVL_WAITING_ENABLED_EMPTY) {
2154
+ long gvl_waiting_at = thread_context->gvl_waiting_at;
2155
+ // Thread was not waiting on gvl
2156
+ if (gvl_waiting_at == 0) {
1921
2157
  return (on_gvl_running_result) {.action = ON_GVL_RUNNING_UNKNOWN, .waiting_for_gvl_duration_ns = 0};
1922
2158
  }
1923
-
1924
2159
  // @ivoanjo: I'm not sure if this can happen -- It means we should've sampled already but haven't gotten the chance yet?
1925
2160
  if (gvl_waiting_at < 0) {
1926
2161
  return (on_gvl_running_result) {.action = ON_GVL_RUNNING_SAMPLE, .waiting_for_gvl_duration_ns = 0};
@@ -1928,16 +2163,30 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1928
2163
 
1929
2164
  long waiting_for_gvl_duration_ns = monotonic_wall_time_now_ns(DO_NOT_RAISE_ON_FAILURE) - gvl_waiting_at;
1930
2165
 
1931
- bool should_sample = waiting_for_gvl_duration_ns >= waiting_for_gvl_threshold_ns;
2166
+ bool should_sample = waiting_for_gvl_duration_ns >= state->waiting_for_gvl_threshold_ns;
1932
2167
 
1933
2168
  if (should_sample) {
1934
2169
  // We flip the gvl_waiting_at to negative to mark that the thread is now running and no longer waiting
1935
- intptr_t gvl_waiting_at_is_now_running = -gvl_waiting_at;
2170
+ long gvl_waiting_at_is_now_running = -gvl_waiting_at;
1936
2171
 
1937
- gvl_profiling_state_set(thread, gvl_waiting_at_is_now_running);
2172
+ thread_context->gvl_waiting_at = gvl_waiting_at_is_now_running;
1938
2173
  } else {
1939
- // We decided not to sample. Let's mark the thread back to the initial "enabled but empty" state
1940
- gvl_profiling_state_set(thread, GVL_WAITING_ENABLED_EMPTY);
2174
+ thread_context->gvl_waiting_at = 0;
2175
+
2176
+ // Even though the GVL wait itself was below threshold, if the thread had skipped samples
2177
+ // (was suspended for a long time without the GVL), we still need to force a sample now.
2178
+ // Otherwise, the accumulated idle wall-time would be reported against whatever stack the
2179
+ // thread runs next, misrepresenting the time spent idle.
2180
+ if (thread_context->was_skipped_at_last_sample) {
2181
+ should_sample = true;
2182
+ }
2183
+ }
2184
+
2185
+ if (should_sample) {
2186
+ // We prepare the sample here because the postponed job might be called some time later,
2187
+ // possibly after some Ruby calls which change the Ruby stack,
2188
+ // and we want to attribute the time acquiring or without the GVL to the correct Ruby stack.
2189
+ prepare_sample_thread(thread, &thread_context->sampling_buffer);
1941
2190
  }
1942
2191
 
1943
2192
  return (on_gvl_running_result) {
@@ -1946,11 +2195,6 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1946
2195
  };
1947
2196
  }
1948
2197
 
1949
- __attribute__((warn_unused_result))
1950
- on_gvl_running_result thread_context_collector_on_gvl_running(gvl_profiling_thread thread) {
1951
- return thread_context_collector_on_gvl_running_with_threshold(thread, global_waiting_for_gvl_threshold_ns);
1952
- }
1953
-
1954
2198
  // Why does this method need to exist?
1955
2199
  //
1956
2200
  // You may be surprised to see that if we never call this function (from cpu_and_wall_time_worker), Waiting for GVL
@@ -1968,7 +2212,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1968
2212
  //
1969
2213
  // Stack:
1970
2214
  // If the thread starts working without the end of the Waiting for GVL sample, then by the time the thread is sampled
1971
- // via the regular cpu/wall-time samples mechanism, the stack can be be inaccurate (e.g. does not correctly pinpoint
2215
+ // via the regular cpu/wall-time samples mechanism, the stack can be inaccurate (e.g. does not correctly pinpoint
1972
2216
  // where the waiting happened).
1973
2217
  //
1974
2218
  // Arguably, the last sample after Waiting for GVL ended (when gvl_waiting_at < 0) should always come from this method
@@ -1977,15 +2221,19 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1977
2221
  //
1978
2222
  // ---
1979
2223
  //
2224
+ // Always called with the GVL, either from a postponed_job or from tests.
2225
+ //
1980
2226
  // NOTE: In normal use, current_thread is expected to be == rb_thread_current(); the `current_thread` parameter only
1981
2227
  // exists to enable testing.
1982
2228
  VALUE thread_context_collector_sample_after_gvl_running(VALUE self_instance, VALUE current_thread, long current_monotonic_wall_time_ns) {
1983
2229
  thread_context_collector_state *state;
1984
2230
  TypedData_Get_Struct(self_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
1985
2231
 
1986
- intptr_t gvl_waiting_at = gvl_profiling_state_thread_object_get(current_thread);
2232
+ per_thread_context *thread_context = get_or_create_context_for(current_thread);
1987
2233
 
1988
- if (gvl_waiting_at >= 0) {
2234
+ long gvl_waiting_at = thread_context->gvl_waiting_at;
2235
+
2236
+ if (gvl_waiting_at >= 0 && !thread_context->was_skipped_at_last_sample) {
1989
2237
  // @ivoanjo: I'm not sure if this can ever happen. This means that we're not on the same thread
1990
2238
  // that ran `thread_context_collector_on_gvl_running` and made the decision to sample OR a regular sample was
1991
2239
  // triggered ahead of us.
@@ -1993,9 +2241,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
1993
2241
  return Qfalse;
1994
2242
  }
1995
2243
 
1996
- per_thread_context *thread_context = get_or_create_context_for(current_thread, state);
1997
-
1998
- // We don't actually account for cpu-time during Waiting for GVL. BUT, we may chose to push an
2244
+ // We don't actually account for cpu-time during Waiting for GVL. BUT, we may choose to push an
1999
2245
  // extra sample to represent the period prior to Waiting for GVL. To support that, we retrieve the current
2000
2246
  // cpu-time of the thread and let `update_metrics_and_sample` decide what to do with it.
2001
2247
  long cpu_time_for_thread = cpu_time_now_ns(thread_context);
@@ -2004,13 +2250,11 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2004
2250
 
2005
2251
  update_metrics_and_sample(
2006
2252
  state,
2007
- /* thread_being_sampled: */ current_thread,
2008
- /* stack_from_thread: */ current_thread,
2253
+ current_thread,
2009
2254
  thread_context,
2010
- &thread_context->sampling_buffer,
2011
2255
  cpu_time_for_thread,
2012
- current_monotonic_wall_time_ns
2013
- );
2256
+ current_monotonic_wall_time_ns,
2257
+ false);
2014
2258
 
2015
2259
  return Qtrue;
2016
2260
  }
@@ -2021,14 +2265,12 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2021
2265
  static bool handle_gvl_waiting(
2022
2266
  thread_context_collector_state *state,
2023
2267
  VALUE thread_being_sampled,
2024
- VALUE stack_from_thread,
2025
2268
  per_thread_context *thread_context,
2026
- sampling_buffer* sampling_buffer,
2027
2269
  long current_cpu_time_ns
2028
2270
  ) {
2029
- intptr_t gvl_waiting_at = gvl_profiling_state_thread_object_get(thread_being_sampled);
2271
+ long gvl_waiting_at = thread_context->gvl_waiting_at;
2030
2272
 
2031
- bool is_gvl_waiting_state = gvl_waiting_at != 0 && gvl_waiting_at != GVL_WAITING_ENABLED_EMPTY;
2273
+ bool is_gvl_waiting_state = gvl_waiting_at != 0;
2032
2274
 
2033
2275
  if (!is_gvl_waiting_state) return false;
2034
2276
 
@@ -2041,17 +2283,17 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2041
2283
  // ...──────────────┬───────────────────...
2042
2284
  // Other state β”‚ Waiting for GVL
2043
2285
  // ...──────────────┴───────────────────...
2044
- // β–² β–²
2286
+ // β–² β–²
2045
2287
  // └─ Previous sample └─ Regular sample (caller)
2046
2288
  //
2047
2289
  // In this case, we'll want to push two samples: a) one for the current time (handled by the caller), b) an extra sample
2048
- // to represent the remaining cpu/wall time before the "Waiting for GVL" started:
2290
+ // to represent the remaining cpu/wall time before the "Waiting for GVL" started (for timeline purposes):
2049
2291
  //
2050
2292
  // time ─────►
2051
2293
  // ...──────────────┬───────────────────...
2052
2294
  // Other state β”‚ Waiting for GVL
2053
2295
  // ...──────────────┴───────────────────...
2054
- // β–² β–² β–²
2296
+ // β–² β–² β–²
2055
2297
  // └─ Prev... └─ Extra sample └─ Regular sample (caller)
2056
2298
  //
2057
2299
  // 2. The current sample is the n-th one after we entered the "Waiting for GVL" state
@@ -2061,7 +2303,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2061
2303
  // ...──────────────┬───────────────────────────────────────────────...
2062
2304
  // Other state β”‚ Waiting for GVL
2063
2305
  // ...──────────────┴───────────────────────────────────────────────...
2064
- // β–² β–² β–²
2306
+ // β–² β–² β–²
2065
2307
  // └─ Previous sample └─ Previous sample └─ Regular sample (caller)
2066
2308
  //
2067
2309
  // In this case, we just report back to the caller that the thread is in the "Waiting for GVL" state.
@@ -2076,7 +2318,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2076
2318
 
2077
2319
  if (gvl_waiting_at < 0) {
2078
2320
  // Negative means the waiting for GVL just ended, so we clear the state, so next samples no longer represent waiting
2079
- gvl_profiling_state_thread_object_set(thread_being_sampled, GVL_WAITING_ENABLED_EMPTY);
2321
+ thread_context->gvl_waiting_at = 0;
2080
2322
  }
2081
2323
 
2082
2324
  long gvl_waiting_started_wall_time_ns = labs(gvl_waiting_at);
@@ -2086,7 +2328,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2086
2328
  &thread_context->cpu_time_at_previous_sample_ns,
2087
2329
  current_cpu_time_ns,
2088
2330
  thread_context->gc_tracking.cpu_time_at_start_ns,
2089
- IS_NOT_WALL_TIME
2331
+ IS_CPU_TIME
2090
2332
  );
2091
2333
 
2092
2334
  long duration_until_start_of_gvl_waiting_ns = update_time_since_previous_sample(
@@ -2100,9 +2342,7 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2100
2342
  trigger_sample_for_thread(
2101
2343
  state,
2102
2344
  thread_being_sampled,
2103
- stack_from_thread,
2104
2345
  thread_context,
2105
- sampling_buffer,
2106
2346
  (sample_values) {.cpu_time_ns = cpu_time_elapsed_ns, .cpu_or_wall_samples = 1, .wall_time_ns = duration_until_start_of_gvl_waiting_ns},
2107
2347
  gvl_waiting_started_wall_time_ns,
2108
2348
  NULL,
@@ -2120,7 +2360,8 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2120
2360
 
2121
2361
  debug_enter_unsafe_context();
2122
2362
 
2123
- thread_context_collector_on_gvl_waiting(thread_from_thread_object(thread));
2363
+ per_thread_context *thread_context = get_per_thread_context(thread);
2364
+ if (thread_context) thread_context_collector_on_gvl_waiting(thread_context);
2124
2365
 
2125
2366
  debug_leave_unsafe_context();
2126
2367
 
@@ -2132,30 +2373,48 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2132
2373
 
2133
2374
  debug_enter_unsafe_context();
2134
2375
 
2135
- intptr_t gvl_waiting_at = gvl_profiling_state_thread_object_get(thread);
2376
+ per_thread_context *thread_context = get_per_thread_context(thread);
2377
+ VALUE result = thread_context ? LONG2NUM(thread_context->gvl_waiting_at) : Qnil;
2136
2378
 
2137
2379
  debug_leave_unsafe_context();
2138
2380
 
2139
- return LONG2NUM(gvl_waiting_at);
2381
+ return result;
2140
2382
  }
2141
2383
 
2142
- static VALUE _native_on_gvl_running(DDTRACE_UNUSED VALUE self, VALUE thread) {
2384
+ static VALUE _native_on_gvl_running(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread) {
2143
2385
  ENFORCE_THREAD(thread);
2144
2386
 
2145
2387
  debug_enter_unsafe_context();
2146
2388
 
2147
- VALUE result = thread_context_collector_on_gvl_running(thread_from_thread_object(thread)).action == ON_GVL_RUNNING_SAMPLE ? Qtrue : Qfalse;
2389
+ per_thread_context *thread_context = get_per_thread_context(thread);
2390
+ VALUE result;
2391
+ if (thread_context) {
2392
+ result = thread_context_collector_on_gvl_running(collector_instance, thread, thread_context).action == ON_GVL_RUNNING_SAMPLE ? Qtrue : Qfalse;
2393
+ } else {
2394
+ result = Qfalse;
2395
+ }
2148
2396
 
2149
2397
  debug_leave_unsafe_context();
2150
2398
 
2151
2399
  return result;
2152
2400
  }
2153
2401
 
2154
- static VALUE _native_sample_after_gvl_running(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread, VALUE allow_exception) {
2402
+ static VALUE _native_on_gvl_released(DDTRACE_UNUSED VALUE self, VALUE thread) {
2155
2403
  ENFORCE_THREAD(thread);
2156
- ENFORCE_BOOLEAN(allow_exception);
2157
2404
 
2405
+ debug_enter_unsafe_context();
2406
+
2407
+ per_thread_context *thread_context = get_per_thread_context(thread);
2408
+ if (thread_context) thread_context_collector_on_gvl_released(thread_context);
2158
2409
 
2410
+ debug_leave_unsafe_context();
2411
+
2412
+ return Qnil;
2413
+ }
2414
+
2415
+ static VALUE _native_sample_after_gvl_running(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread, VALUE allow_exception) {
2416
+ ENFORCE_THREAD(thread);
2417
+ ENFORCE_BOOLEAN(allow_exception);
2159
2418
 
2160
2419
  if (allow_exception == Qfalse) debug_enter_unsafe_context();
2161
2420
 
@@ -2170,31 +2429,27 @@ static uint64_t otel_span_id_to_uint(VALUE otel_span_id) {
2170
2429
  return result;
2171
2430
  }
2172
2431
 
2173
- static VALUE _native_apply_delta_to_cpu_time_at_previous_sample_ns(DDTRACE_UNUSED VALUE self, VALUE collector_instance, VALUE thread, VALUE delta_ns) {
2174
- ENFORCE_THREAD(thread);
2175
-
2176
- thread_context_collector_state *state;
2177
- TypedData_Get_Struct(collector_instance, thread_context_collector_state, &thread_context_collector_typed_data, state);
2178
-
2179
- per_thread_context *thread_context = get_context_for(thread, state);
2180
- if (thread_context == NULL) raise_error(rb_eArgError, "Unexpected: This method cannot be used unless the per-thread context for the thread already exists");
2181
-
2182
- thread_context->cpu_time_at_previous_sample_ns += NUM2LONG(delta_ns);
2183
-
2184
- return Qtrue;
2185
- }
2186
-
2187
2432
  #else
2188
2433
  static bool handle_gvl_waiting(
2189
2434
  DDTRACE_UNUSED thread_context_collector_state *state,
2190
2435
  DDTRACE_UNUSED VALUE thread_being_sampled,
2191
- DDTRACE_UNUSED VALUE stack_from_thread,
2192
2436
  DDTRACE_UNUSED per_thread_context *thread_context,
2193
- DDTRACE_UNUSED sampling_buffer* sampling_buffer,
2194
2437
  DDTRACE_UNUSED long current_cpu_time_ns
2195
2438
  ) { return false; }
2439
+
2196
2440
  #endif // NO_GVL_INSTRUMENTATION
2197
2441
 
2442
+ static VALUE _native_apply_delta_to_cpu_time_at_previous_sample_ns(DDTRACE_UNUSED VALUE self, VALUE thread, VALUE delta_ns) {
2443
+ ENFORCE_THREAD(thread);
2444
+
2445
+ per_thread_context *thread_context = get_per_thread_context(thread);
2446
+ if (thread_context == NULL) raise_error(rb_eArgError, "Unexpected: This method cannot be used unless the per-thread context for the thread already exists");
2447
+
2448
+ thread_context->cpu_time_at_previous_sample_ns += NUM2LONG(delta_ns);
2449
+
2450
+ return Qtrue;
2451
+ }
2452
+
2198
2453
  #define MAX_SAFE_LOOKUP_SIZE 16
2199
2454
 
2200
2455
  typedef struct { VALUE lookup_key; VALUE result; } safe_lookup_hash_state;
@@ -2239,6 +2494,6 @@ static VALUE _native_system_epoch_time_now_ns(DDTRACE_UNUSED VALUE self, VALUE c
2239
2494
  return LONG2NUM(system_epoch_time_ns);
2240
2495
  }
2241
2496
 
2242
- static VALUE _native_prepare_sample_inside_signal_handler(DDTRACE_UNUSED VALUE self, VALUE collector_instance) {
2243
- return thread_context_collector_prepare_sample_inside_signal_handler(collector_instance) ? Qtrue : Qfalse;
2497
+ static VALUE _native_prepare_sample_inside_signal_handler(DDTRACE_UNUSED VALUE self) {
2498
+ return thread_context_collector_prepare_sample_inside_signal_handler() ? Qtrue : Qfalse;
2244
2499
  }