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,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../symbol_database'
3
4
  require_relative 'extractor'
4
5
  require_relative 'logger'
5
6
  require_relative 'scope_batcher'
6
7
  require_relative 'uploader'
7
8
  require_relative '../core/utils/time'
9
+ require_relative '../di/fatal_exceptions'
8
10
 
9
11
  module Datadog
10
12
  module SymbolDatabase
@@ -17,16 +19,24 @@ module Datadog
17
19
  # - Debounce: extraction is deferred by EXTRACT_DEBOUNCE_INTERVAL seconds so
18
20
  # reconfigurations during boot coalesce into a single extraction on the
19
21
  # final Component instance.
22
+ # - Hot-load coverage: TracePoint :class hook captures classes loaded after
23
+ # initial extraction, enqueues them on a per-instance buffer; the scheduler
24
+ # drains the buffer on debounce and extracts each one via Extractor#extract,
25
+ # matching Java/Python/.NET continuous coverage.
20
26
  #
21
27
  # Upload flow:
22
28
  # 1. Remote config sends upload_symbols: true (or force_upload mode)
23
29
  # 2. start_upload called — schedules extraction EXTRACT_DEBOUNCE_INTERVAL
24
- # seconds in the future on a per-instance scheduler thread.
30
+ # seconds in the future on a per-instance scheduler thread, and lazily
31
+ # installs the TracePoint :class hook if not already installed.
25
32
  # 3. When the timer fires (no further start_upload calls reset it),
26
- # extract_and_upload runs: ObjectSpace iteration → Extractor → ScopeBatcher.
33
+ # extract_and_upload runs. On the first call: ObjectSpace iteration →
34
+ # Extractor#extract_all. On subsequent calls: drain the hot-load buffer →
35
+ # Extractor#extract per module.
27
36
  # 4. ScopeBatcher batches and triggers Uploader.
28
- # 5. A class-level flag is set so subsequent Component instances created via
29
- # Datadog reconfiguration do not re-upload.
37
+ # 5. As new classes load throughout the process lifetime, the TracePoint hook
38
+ # fires and signals the scheduler — the next debounce window produces an
39
+ # incremental upload of just the new classes.
30
40
  #
31
41
  # Created by: Components#initialize (in Core::Configuration::Components)
32
42
  # Accessed by: Remote config receiver via Datadog.send(:components).symbol_database
@@ -39,57 +49,31 @@ module Datadog
39
49
  # Long enough to absorb reconfiguration cascades during Rails boot.
40
50
  EXTRACT_DEBOUNCE_INTERVAL = 5 # seconds
41
51
 
42
- # Class-level state: tracks whether any Component instance in this process
43
- # has performed an extract+upload. Survives Component replacement during
44
- # Datadog reconfiguration so duplicate uploads are prevented.
45
- @uploaded_this_process = false
46
- @upload_done_mutex = Mutex.new
47
- @upload_done_cv = ConditionVariable.new
48
-
49
- class << self
50
- attr_reader :upload_done_mutex, :upload_done_cv
51
-
52
- # Whether any Component instance in this process has completed an
53
- # upload. Cross-instance flag used to dedupe uploads across
54
- # Component rebuilds within a single Ruby process.
55
- # @return [Boolean]
56
- def uploaded_this_process?
57
- @upload_done_mutex.synchronize { @uploaded_this_process }
58
- end
59
-
60
- # Mark the current process as having completed a symbol upload.
61
- # Called by the Component instance that successfully completes an
62
- # upload; subsequent start_upload calls on any instance short-circuit.
63
- # @return [void]
64
- def mark_uploaded
65
- @upload_done_mutex.synchronize do
66
- @uploaded_this_process = true
67
- @upload_done_cv.broadcast
68
- end
69
- end
70
-
71
- # Reset class-level upload state. Test-only.
72
- # @api private
73
- def reset_uploaded_this_process_for_tests!
74
- @upload_done_mutex.synchronize { @uploaded_this_process = false }
75
- end
76
- end
77
-
78
- # Build a new Component if feature is enabled and dependencies met.
52
+ # Cached unbound Module#singleton_class? dispatched explicitly inside the
53
+ # hot-load TracePoint so user code that overrides `singleton_class?` (e.g.
54
+ # `def self.singleton_class?(arg)`) cannot raise inside the :class hook and
55
+ # abort the user's class definition. Mirrors the cache in Extractor.
56
+ MODULE_SINGLETON_CLASS_PRED = Module.instance_method(:singleton_class?)
57
+ private_constant :MODULE_SINGLETON_CLASS_PRED
58
+
59
+ # Build a new Component if the runtime supports it and dependencies are met.
60
+ # The caller (Core::Configuration::Components) decides whether the feature is
61
+ # enabled and only invokes build when it is, so a disabled component is never
62
+ # constructed. This method gates only on symbol database's own requirements
63
+ # (supported platform, remote config availability).
79
64
  # @param settings [Configuration::Settings] Tracer settings
80
65
  # @param agent_settings [Configuration::AgentSettings] Agent configuration
81
66
  # @param logger [Logger] Logger instance
82
67
  # @param telemetry [Core::Telemetry::Component, nil] Telemetry component for error reporting
83
- # @return [Component, nil] Component instance or nil if not enabled/requirements not met
84
- def self.build(settings, agent_settings, logger, telemetry: nil)
68
+ # @param di_active [Proc, nil] Predicate returning whether Dynamic
69
+ # Instrumentation is currently active (started). Gates remote-config
70
+ # uploads in the nil-default case; nil means "no gate" (standalone
71
+ # force_upload contexts).
72
+ # @return [Component, nil] Component instance or nil if requirements not met
73
+ def self.build(settings, agent_settings, logger, telemetry: nil, di_active: nil)
85
74
  symdb_logger = SymbolDatabase::Logger.new(settings, logger)
86
75
 
87
- unless settings.respond_to?(:symbol_database) && settings.symbol_database.enabled
88
- symdb_logger.debug("symdb: symbol database upload not enabled, skipping")
89
- return
90
- end
91
-
92
- # Symbol database requires MRI Ruby 2.6+.
76
+ # Symbol database requires MRI Ruby 2.7+.
93
77
  # Configuration accessors (settings.symbol_database.*) remain available on all
94
78
  # platforms — only the component (upload) is disabled on unsupported engines/versions.
95
79
  # environment_supported? logs the specific reason (engine or version) internally.
@@ -101,7 +85,7 @@ module Datadog
101
85
  return nil
102
86
  end
103
87
 
104
- new(settings, agent_settings, symdb_logger, telemetry: telemetry).tap do |component|
88
+ new(settings, agent_settings, symdb_logger, telemetry: telemetry, di_active: di_active).tap do |component|
105
89
  # Defer extraction if force upload mode — wait for app boot to complete
106
90
  component.schedule_deferred_upload if settings.symbol_database.internal.force_upload
107
91
  end
@@ -114,11 +98,13 @@ module Datadog
114
98
  # @param agent_settings [Configuration::AgentSettings] Agent configuration
115
99
  # @param logger [Logger] Logger instance
116
100
  # @param telemetry [Core::Telemetry::Component, nil] Telemetry component for error reporting
117
- def initialize(settings, agent_settings, logger, telemetry: nil)
101
+ # @param di_active [Proc, nil] Predicate returning whether Dynamic Instrumentation is currently active
102
+ def initialize(settings, agent_settings, logger, telemetry: nil, di_active: nil)
118
103
  @settings = settings
119
104
  @agent_settings = agent_settings
120
105
  @logger = logger
121
106
  @telemetry = telemetry
107
+ @di_active = di_active
122
108
 
123
109
  @extractor = Extractor.new(logger: logger, settings: settings)
124
110
  @uploader = Uploader.new(settings: settings, agent_settings: agent_settings, logger: logger, telemetry: telemetry)
@@ -130,6 +116,16 @@ module Datadog
130
116
  @upload_in_progress = false
131
117
  @upload_in_progress_cv = ConditionVariable.new
132
118
  @shutdown = false
119
+ # PID at construction time. Compared against Process.pid in shutdown!
120
+ # to detect forked-child callers, whose inherited @upload_in_progress
121
+ # snapshot is stale: the scheduler thread that would clear it lives
122
+ # only in the parent. See shutdown! for details.
123
+ @owner_pid = Process.pid
124
+
125
+ # Signalled when @last_upload_time advances. wait_for_idle blocks on this
126
+ # so short-lived scripts that trigger an upload can wait for an upload
127
+ # attempt to complete without depending on a one-shot flag.
128
+ @last_upload_time_cv = ConditionVariable.new
133
129
 
134
130
  # Per-instance scheduler state. The scheduler thread is started lazily
135
131
  # on the first start_upload call.
@@ -138,6 +134,25 @@ module Datadog
138
134
  @scheduled_at = nil
139
135
  @scheduler_signaled = false
140
136
  @scheduler_thread = nil
137
+
138
+ # Hot-load coverage state. TracePoint :class hook is installed lazily on
139
+ # the first start_upload call; classes defined after that point are
140
+ # enqueued here and drained by the scheduler on debounce. Distinguishes
141
+ # initial extraction (extract_all) from incremental (per-module extract).
142
+ @hot_load_buffer = []
143
+ @hot_load_buffer_mutex = Mutex.new
144
+ @hot_load_tracepoint = nil
145
+ @initial_extraction_done = false
146
+
147
+ # Sticky record of "remote config (or force mode) wants symbols
148
+ # uploaded", independent of whether DI is currently active. Set when an
149
+ # upload is requested and either allowed or deferred by the DI gate;
150
+ # cleared only when RC explicitly disables uploads (stop_upload). It
151
+ # survives stop_for_di_disable's scheduler teardown, so
152
+ # resume_pending_upload can restart uploads after a DI disable->re-enable
153
+ # cycle even though RC does not re-dispatch the unchanged symbol-database
154
+ # config.
155
+ @upload_requested = false
141
156
  end
142
157
 
143
158
  # Schedule a deferred upload that waits for app boot to complete.
@@ -149,9 +164,10 @@ module Datadog
149
164
  # In non-Rails: triggers start_upload immediately.
150
165
  #
151
166
  # Each Component registers its own callback. Old Components that have
152
- # been shut down short-circuit in start_upload via @shutdown.
153
- # Cross-process deduplication is handled by the class-level
154
- # uploaded_this_process? flag, not by guarding registration.
167
+ # been shut down short-circuit in start_upload via @shutdown. The hot-load
168
+ # hook handles classes loaded after this initial trigger, so under
169
+ # eager_load=false an under-extracted initial upload self-corrects as the
170
+ # app exercises code.
155
171
  #
156
172
  # @return [void]
157
173
  def schedule_deferred_upload
@@ -160,18 +176,8 @@ module Datadog
160
176
  # loaded object (Rails::Application), so a bare `start_upload`
161
177
  # would resolve against it.
162
178
  component = self
163
- logger = @logger
164
179
  ::ActiveSupport.on_load(:after_initialize) do
165
- # Only auto-trigger when Rails has eager-loaded application
166
- # classes during initialization. In dev (eager_load=false)
167
- # there is nothing complete to extract; the auto-deferred
168
- # upload would race with explicit triggers and produce
169
- # under-extracted uploads.
170
- if defined?(::Rails) && ::Rails.application&.config&.eager_load # steep:ignore NoMethod
171
- component.start_upload
172
- else
173
- logger.debug { "symdb: skipping auto-deferred upload (eager_load disabled)" }
174
- end
180
+ component.start_upload
175
181
  end
176
182
  else
177
183
  start_upload
@@ -190,60 +196,133 @@ module Datadog
190
196
  # Thread-safe: can be called concurrently from multiple remote config updates.
191
197
  # @return [void]
192
198
  def start_upload
193
- return if Component.uploaded_this_process?
194
-
195
199
  @scheduler_mutex.synchronize do
196
200
  return if @shutdown
197
201
 
202
+ unless upload_allowed?
203
+ if deferred_by_di_gate?
204
+ # nil-default case: Symbol Database mirrors Dynamic Instrumentation
205
+ # and DI is not active. Record the desire and defer; resume_pending_upload
206
+ # re-attempts when DI is enabled. Without this gate the tracer would
207
+ # extract and upload symbols for applications that never enabled DI.
208
+ @upload_requested = true
209
+ @logger.debug("symdb: upload requested but Dynamic Instrumentation is not active; deferring until DI is enabled")
210
+ else
211
+ # Explicit symbol_database.enabled = false: the feature is disabled,
212
+ # not merely waiting on DI. Clear the desire so resume_pending_upload
213
+ # does not retry a disabled feature.
214
+ @upload_requested = false
215
+ @logger.debug("symdb: upload requested but symbol database upload is disabled; skipping")
216
+ end
217
+ return
218
+ end
219
+ @upload_requested = true
220
+
221
+ if @owner_pid != Process.pid
222
+ # Forked child: claim ownership and clear inherited
223
+ # @upload_in_progress. The inherited flag was the parent's
224
+ # snapshot; the parent's scheduler thread does not exist in this
225
+ # process. Any upload starting now is child-owned and must be
226
+ # waited on in shutdown! via the PID-match branch.
227
+ @owner_pid = Process.pid
228
+ @mutex.synchronize { @upload_in_progress = false }
229
+ end
230
+
231
+ install_hot_load_hook
198
232
  @scheduled_at = Datadog::Core::Utils::Time.get_time + EXTRACT_DEBOUNCE_INTERVAL
199
233
  @scheduler_signaled = true
200
234
  @scheduler_cv.signal
201
235
  ensure_scheduler_thread
202
236
  end
203
- rescue => e
237
+ rescue Exception => e # standard:disable Lint/RescueException
238
+ Datadog::DI.reraise_if_fatal(e)
204
239
  @logger.debug { "symdb: error scheduling upload: #{e.class}: #{e.message}" }
205
240
  @telemetry&.report(e, description: 'symdb: error scheduling upload')
206
241
  end
207
242
 
208
- # Stop symbol upload (cancel the scheduler).
243
+ # Stop symbol upload in response to remote config sending
244
+ # upload_symbols: false or deleting the config: the customer no longer wants
245
+ # uploads, so clear the sticky @upload_requested desire (a later
246
+ # resume_pending_upload must not restart it) and tear down the scheduler and
247
+ # hot-load hook.
209
248
  # Thread-safe: can be called concurrently from multiple remote config updates.
210
249
  # @return [void]
211
250
  def stop_upload
212
- @scheduler_mutex.synchronize do
213
- @scheduled_at = nil
214
- @scheduler_signaled = true
215
- @scheduler_cv.signal
216
- end
251
+ @scheduler_mutex.synchronize { @upload_requested = false }
252
+ suspend_scheduling
253
+ end
254
+
255
+ # Re-attempt a symbol upload that remote config requested but that is not
256
+ # currently running because Dynamic Instrumentation was inactive — either
257
+ # deferred at request time, or suspended by stop_for_di_disable when DI was
258
+ # turned off. Called from the orchestration layer (Tracing::Remote) when DI
259
+ # is enabled via remote configuration (implicit enablement). No-op unless an
260
+ # upload was requested and not since disabled. Mirrors DI's
261
+ # replay_current_probes: RC does not re-dispatch the unchanged
262
+ # symbol-database config on DI re-enable, so the tracer restarts the upload
263
+ # from its own retained desire.
264
+ # @return [void]
265
+ def resume_pending_upload
266
+ requested = @scheduler_mutex.synchronize { @upload_requested }
267
+ start_upload if requested
268
+ nil
269
+ end
270
+
271
+ # Stop uploading when Dynamic Instrumentation is disabled via remote
272
+ # configuration. Only the nil-default (follows-DI) case stops; an explicit
273
+ # symbol_database.enabled = true and force_upload are independent of DI and
274
+ # keep running. Called from the orchestration layer (Tracing::Remote) so
275
+ # Symbol Database's TracePoint and scheduler don't keep uploading after DI
276
+ # is turned off. No-op if uploads were never started.
277
+ # @return [void]
278
+ def stop_for_di_disable
279
+ return if @settings.symbol_database.internal.force_upload
280
+ return unless @settings.symbol_database.enabled.nil?
281
+
282
+ # Suspend, don't stop: preserve @upload_requested so resume_pending_upload
283
+ # restarts the upload when DI is re-enabled and RC never re-sends the
284
+ # (unchanged) symbol-database config.
285
+ suspend_scheduling
217
286
  end
218
287
 
219
- # Block until any Component in this process has finished an extract+upload,
288
+ # Block until this Component finishes an extract+upload after this call,
220
289
  # or until the timeout elapses. Used by short-lived scripts that trigger
221
290
  # an upload via force_upload and need to wait before exiting.
291
+ # Tracks @last_upload_time advance — returns true once any upload attempt
292
+ # completes (success or failure), false on timeout.
222
293
  # @param timeout [Numeric] Maximum seconds to wait
223
294
  # @return [Boolean] true if an upload completed; false on timeout
224
295
  def wait_for_idle(timeout: 30)
225
296
  deadline = Datadog::Core::Utils::Time.get_time + timeout
226
- Component.upload_done_mutex.synchronize do
227
- # Read @uploaded_this_process directly: we already hold
228
- # Component.upload_done_mutex here, and uploaded_this_process?
229
- # would try to re-acquire it (non-reentrant), deadlocking.
230
- until Component.instance_variable_get(:@uploaded_this_process)
297
+ @mutex.synchronize do
298
+ start_time = @last_upload_time
299
+ while @last_upload_time == start_time
231
300
  remaining = deadline - Datadog::Core::Utils::Time.get_time
232
301
  return false if remaining <= 0
233
- Component.upload_done_cv.wait(Component.upload_done_mutex, remaining)
302
+ @last_upload_time_cv.wait(@mutex, remaining)
234
303
  end
235
304
  end
236
305
  true
237
306
  end
238
307
 
239
308
  # Shutdown component and cleanup resources.
240
- # Cancels the per-instance scheduler so any pending debounced extraction
241
- # is dropped. Waits for an in-flight extraction to complete before
242
- # returning. Does not touch class-level state, so a sibling Component
243
- # built after shutdown can still upload.
309
+ # Disables the hot-load TracePoint so no events queue for a dead
310
+ # scheduler. Cancels the per-instance scheduler so any pending debounced
311
+ # extraction is dropped. Waits for an in-flight extraction to complete
312
+ # before returning. Does not touch any sibling Components, so a sibling
313
+ # Component built after shutdown can still upload.
314
+ # The TracePoint teardown sits inside the same @scheduler_mutex critical
315
+ # section as the @shutdown flag flip, so it is atomic against a concurrent
316
+ # start_upload (which installs the TracePoint under @scheduler_mutex). Without
317
+ # that, a shutdown interleaved with a start could leave an enabled TracePoint
318
+ # rooted by the VM — class loads would keep growing @hot_load_buffer for the
319
+ # rest of the process lifetime (enqueue_hot_load's @shutdown check skips
320
+ # re-scheduling but only after the buffer push).
244
321
  # @return [void]
245
322
  def shutdown!
246
323
  @scheduler_mutex.synchronize do
324
+ @hot_load_tracepoint&.disable
325
+ @hot_load_tracepoint = nil
247
326
  @shutdown = true
248
327
  @scheduler_signaled = true
249
328
  @scheduler_cv.signal
@@ -253,32 +332,178 @@ module Datadog
253
332
 
254
333
  @mutex.synchronize do
255
334
  if @upload_in_progress
256
- @upload_in_progress_cv.wait(@mutex, 5)
335
+ if Process.pid == @owner_pid
336
+ @upload_in_progress_cv.wait(@mutex, 5)
337
+ else
338
+ # We are in a forked child that inherited this Component but
339
+ # never called start_upload here. The scheduler thread (the
340
+ # only writer that clears @upload_in_progress and signals the
341
+ # cv) lives only in the parent — fork carries only the calling
342
+ # thread, so nothing in this process can ever signal us.
343
+ # Waiting would burn the full 5s timeout for no benefit. Treat
344
+ # the inherited @upload_in_progress as a stale snapshot and
345
+ # proceed; the parent's shutdown! (running in the parent) is
346
+ # authoritative. Child-owned uploads (where start_upload was
347
+ # called in this process) take the PID-match branch above,
348
+ # because start_upload claims @owner_pid for the current
349
+ # process.
350
+ @upload_in_progress = false
351
+ end
257
352
  end
258
353
  end
259
354
 
260
355
  @scope_batcher.shutdown
261
356
  end
262
357
 
358
+ # Reinitialize per-instance state in a forked child process.
359
+ #
360
+ # `Process.fork` copies the parent's memory but only the forking thread
361
+ # survives in the child. Background threads (`@scheduler_thread`) are
362
+ # dead, mutexes and condition variables are copied without owner
363
+ # tracking (orphan-lock risk if the parent held a mutex at the fork
364
+ # instant), and the TracePoint hook is bound to the dead scheduler.
365
+ #
366
+ # State reset (the child does its own initial extraction, then hot-load
367
+ # continues from there):
368
+ # - Hot-load buffer cleared — the child will rediscover via extract_all.
369
+ # - `@initial_extraction_done = false` — child has not extracted yet.
370
+ # - `@hot_load_tracepoint = nil` — `start_upload` reinstalls a fresh one
371
+ # bound to the child's component.
372
+ # - `@scheduler_thread = nil`, `@scheduled_at = nil`,
373
+ # `@scheduler_signaled = false` — scheduler restarts on next
374
+ # `start_upload`.
375
+ # - `@upload_in_progress = false` — parent may have been mid-upload at
376
+ # the fork instant; the child has no upload in flight.
377
+ # - `@scope_batcher` replaced with a fresh instance. The inherited batcher
378
+ # carries the parent's `@uploaded_modules` set, which `add_scope` uses
379
+ # to dedup by scope name. Without a fresh batcher, the child's
380
+ # re-extraction silently drops every scope whose name the parent
381
+ # already uploaded — under `preload_app!` that's most of the app.
382
+ #
383
+ # Mutex/CV reinit (orphan-lock guard):
384
+ # - `@scheduler_mutex`, `@scheduler_cv`, `@mutex`,
385
+ # `@upload_in_progress_cv`, `@last_upload_time_cv`,
386
+ # `@hot_load_buffer_mutex`.
387
+ #
388
+ # Force-upload mode: the parent's scheduled extraction is dead in the
389
+ # child, so re-register the deferred-upload callback. In Rails the
390
+ # `:after_initialize` hook has already fired (initialization happened
391
+ # in the parent), so the on_load block runs immediately and the child
392
+ # schedules its own upload. In non-Rails, this calls `start_upload`
393
+ # directly.
394
+ #
395
+ # Cross-process upload deduplication is intentionally not handled here.
396
+ # Each forked Component does its own initial extraction. Workers in
397
+ # `preload_app! + eager_load=true` deployments hold identical code to
398
+ # the parent — backend dedup of identical-content uploads is the
399
+ # backend's responsibility, not the tracer's.
400
+ #
401
+ # @return [void]
402
+ def after_fork!
403
+ # Disable the inherited TracePoint before dropping the reference: fork
404
+ # copies the enabled TP into the child, where it remains rooted by the
405
+ # VM. Without an explicit disable, every subsequent class load in the
406
+ # child would enqueue through the inherited hook in addition to the
407
+ # fresh hook that start_upload installs.
408
+ @hot_load_tracepoint&.disable
409
+ @hot_load_buffer = []
410
+ @hot_load_buffer_mutex = Mutex.new
411
+ @hot_load_tracepoint = nil
412
+ @initial_extraction_done = false
413
+
414
+ @scheduler_mutex = Mutex.new
415
+ @scheduler_cv = ConditionVariable.new
416
+ @scheduled_at = nil
417
+ @scheduler_signaled = false
418
+ @scheduler_thread = nil
419
+
420
+ @mutex = Mutex.new
421
+ @upload_in_progress = false
422
+ @upload_in_progress_cv = ConditionVariable.new
423
+ @last_upload_time_cv = ConditionVariable.new
424
+
425
+ # Fresh ScopeBatcher: the inherited one carries the parent's
426
+ # @uploaded_modules set, against which add_scope dedups by name.
427
+ @scope_batcher = ScopeBatcher.new(@uploader, logger: @logger)
428
+
429
+ schedule_deferred_upload if @settings.symbol_database.internal.force_upload
430
+ end
431
+
263
432
  private
264
433
 
265
- # Check whether the runtime environment supports symbol database upload.
266
- # Only MRI Ruby 2.6+ is supported. JRuby and TruffleRuby are not supported
267
- # because ObjectSpace iteration and Method#source_location behave differently.
268
- # Configuration accessors remain available on all platforms this only gates
269
- # the component (upload) itself.
434
+ # Tear down the scheduler and hot-load hook without clearing the sticky
435
+ # @upload_requested desire, so resume_pending_upload can restart uploads
436
+ # that RC still wants. Disables the TracePoint :class hook so post-stop
437
+ # class loads don't re-arm the scheduler, clears the hot-load buffer, and
438
+ # resets @initial_extraction_done so a future resume performs a fresh
439
+ # extract_all instead of draining an empty buffer.
440
+ # The TracePoint teardown sits inside the same @scheduler_mutex critical
441
+ # section as the @scheduled_at reset, so it is atomic against a concurrent
442
+ # start_upload (which installs the TracePoint under @scheduler_mutex). Without
443
+ # that, a stop interleaved with a start could leave an enabled TracePoint
444
+ # rooted by the VM after this returned.
445
+ # @return [void]
446
+ def suspend_scheduling
447
+ @scheduler_mutex.synchronize do
448
+ @hot_load_tracepoint&.disable
449
+ @hot_load_tracepoint = nil
450
+ @scheduled_at = nil
451
+ @scheduler_signaled = true
452
+ @scheduler_cv.signal
453
+ end
454
+ @hot_load_buffer_mutex.synchronize { @hot_load_buffer.clear }
455
+ @initial_extraction_done = false
456
+ nil
457
+ end
458
+
459
+ # Whether a remote-config-triggered upload may proceed now.
460
+ #
461
+ # force_upload and an explicit `symbol_database.enabled = true` both mean
462
+ # "upload regardless of Dynamic Instrumentation". Only the nil-default case
463
+ # — where Symbol Database mirrors DI — is gated on DI actually being active,
464
+ # so the tracer never extracts symbols for an application that never
465
+ # enabled Dynamic Instrumentation.
466
+ # @return [bool]
467
+ def upload_allowed?
468
+ return true if @settings.symbol_database.internal.force_upload
469
+
470
+ case @settings.symbol_database.enabled
471
+ when true then true
472
+ when false then false
473
+ # steep:ignore NoMethod — Steep does not narrow @di_active to non-nil after the .nil? check
474
+ else @di_active.nil? || @di_active.call # steep:ignore NoMethod
475
+ end
476
+ end
477
+
478
+ # Whether upload is currently blocked specifically by the nil-default
479
+ # DI-active gate — the only case that defers and retries via
480
+ # resume_pending_upload. An explicit symbol_database.enabled = false is a
481
+ # disabled feature, not a deferral, so it clears @upload_requested and is
482
+ # not retried. force_upload and explicit true are never gated, so they are
483
+ # never deferred.
484
+ # @return [bool]
485
+ def deferred_by_di_gate?
486
+ return false if @settings.symbol_database.internal.force_upload
487
+ return false unless @settings.symbol_database.enabled.nil?
488
+
489
+ # After the guards above, upload_allowed? reduces to the DI-active gate,
490
+ # so a disallowed upload here is precisely a DI-gate deferral.
491
+ !upload_allowed?
492
+ end
493
+
494
+ # Check whether the runtime environment supports symbol database upload,
495
+ # logging the reason when it does not.
270
496
  # @param logger [Logger]
271
497
  # @return [Boolean]
272
498
  def self.environment_supported?(logger)
499
+ return true if SymbolDatabase.supported_runtime?
500
+
273
501
  if RUBY_ENGINE != 'ruby'
274
502
  logger.debug { "symdb: not supported on #{RUBY_ENGINE}, skipping" }
275
- return false
276
- end
277
- if RUBY_VERSION < '2.6'
278
- logger.debug { "symdb: requires Ruby 2.6+, running #{RUBY_VERSION}, skipping" }
279
- return false
503
+ else
504
+ logger.debug { "symdb: requires Ruby 2.7+, running #{RUBY_VERSION}, skipping" }
280
505
  end
281
- true
506
+ false
282
507
  end
283
508
  private_class_method :environment_supported?
284
509
 
@@ -291,7 +516,9 @@ module Datadog
291
516
  end
292
517
 
293
518
  # Scheduler thread main loop. Waits for the debounce window to elapse,
294
- # then runs extract_and_upload exactly once for this Component.
519
+ # then runs extract_and_upload. Loops indefinitely so that hot-load
520
+ # signals fired after the initial upload trigger subsequent incremental
521
+ # uploads.
295
522
  # @return [void]
296
523
  def scheduler_loop
297
524
  loop do
@@ -301,27 +528,31 @@ module Datadog
301
528
 
302
529
  @scheduler_mutex.synchronize do
303
530
  return if @shutdown
304
- return if Component.uploaded_this_process?
305
531
 
306
532
  # Copy to local so Steep narrows `Float?` to `Float` in the else branch.
307
533
  # Steep does not track narrowing on instance variables across nil checks.
308
534
  scheduled_at = @scheduled_at
309
535
  if scheduled_at.nil?
310
- # Nothing scheduled (e.g. stop_upload cleared it). Wait
311
- # indefinitely for a signal, then re-evaluate on next loop.
536
+ # Nothing scheduled (e.g. stop_upload cleared it, or no hot-load
537
+ # events since the last upload). Wait indefinitely for a signal,
538
+ # then re-evaluate on next loop.
312
539
  @scheduler_signaled = false
313
540
  @scheduler_cv.wait(@scheduler_mutex)
314
541
  else
315
542
  remaining = scheduled_at - Datadog::Core::Utils::Time.get_time
316
543
  if remaining > 0
317
544
  # Wait until the debounce deadline. Any signal (start_upload,
318
- # stop_upload, shutdown!) wakes us early; we always re-loop
319
- # and recompute rather than firing immediately on wake.
545
+ # stop_upload, shutdown!, hot-load event) wakes us early; we
546
+ # always re-loop and recompute rather than firing immediately
547
+ # on wake.
320
548
  @scheduler_signaled = false
321
549
  @scheduler_cv.wait(@scheduler_mutex, remaining)
322
550
  else
323
- # Deadline elapsed without further signal — fire after releasing the mutex.
551
+ # Deadline elapsed without further signal — fire after releasing
552
+ # the mutex. Clear @scheduled_at so the next loop iteration
553
+ # waits for the next start_upload or hot-load signal.
324
554
  should_fire = true
555
+ @scheduled_at = nil
325
556
  end
326
557
  end
327
558
  end
@@ -333,20 +564,18 @@ module Datadog
333
564
 
334
565
  # Outside the mutex.
335
566
  return if @shutdown
336
- if Component.uploaded_this_process?
337
- return
338
- end
339
567
 
340
568
  extract_and_upload
341
- Component.mark_uploaded
342
- return
343
569
  end
344
- rescue => e
570
+ rescue Exception => e # standard:disable Lint/RescueException
571
+ Datadog::DI.reraise_if_fatal(e)
345
572
  @logger.debug { "symdb: scheduler error: #{e.class}: #{e.message}" }
346
573
  @telemetry&.report(e, description: 'symdb: scheduler error')
347
574
  end
348
575
 
349
- # Extract symbols from all loaded modules and upload.
576
+ # Extract symbols and upload. First call runs extract_all (full ObjectSpace
577
+ # walk); subsequent calls drain the hot-load buffer and extract just the
578
+ # newly-loaded modules via Extractor#extract.
350
579
  # @return [void]
351
580
  def extract_and_upload
352
581
  @mutex.synchronize { @upload_in_progress = true }
@@ -355,30 +584,51 @@ module Datadog
355
584
  @logger.trace { "symdb: starting extraction and upload" }
356
585
  start_time = Datadog::Core::Utils::Time.get_time
357
586
 
358
- # Extract symbols from all loaded modules grouped by source file.
359
- # extract_all handles ObjectSpace iteration, filtering, and FQN-based nesting.
360
- file_scopes = @extractor.extract_all
361
587
  extracted_count = 0
362
- file_scopes.each do |scope|
588
+ targetable_count = 0
589
+ consume = lambda do |scope|
363
590
  @scope_batcher.add_scope(scope)
364
591
  extracted_count += 1
592
+ targetable_count += count_targetable_methods_in_scope(scope)
365
593
  log_scope_tree(scope, 0)
366
594
  end
367
595
 
368
- @logger.debug do
369
- extraction_duration = Datadog::Core::Utils::Time.get_time - start_time
370
- targetable_count = count_targetable_methods(file_scopes)
371
- "symdb: extracted #{extracted_count} scopes (#{targetable_count} methods with targetable lines) in #{'%.2f' % extraction_duration}s"
596
+ if @initial_extraction_done
597
+ extract_hot_load_buffer.each(&consume)
598
+ mode_label = "hot-load"
599
+ else
600
+ # Discard any TracePoint events captured between hook install and
601
+ # this initial scan — extract_all walks ObjectSpace which already
602
+ # covers everything loaded at this moment. Anything loaded during
603
+ # or after extract_all stays buffered for the next drain.
604
+ @hot_load_buffer_mutex.synchronize { @hot_load_buffer.clear }
605
+ # Stream form of extract_all yields one FILE scope at a time and frees
606
+ # the per-file intermediate tree as it goes — the full Array<Scope> is
607
+ # never materialized, keeping peak memory bounded for large workspaces.
608
+ @extractor.extract_all(&consume)
609
+ @initial_extraction_done = true
610
+ mode_label = "initial"
372
611
  end
373
612
 
613
+ extraction_duration = Datadog::Core::Utils::Time.get_time - start_time
614
+ @logger.debug { "symdb: #{mode_label} extracted #{extracted_count} scopes (#{targetable_count} methods with targetable lines) in #{'%.2f' % extraction_duration}s" }
615
+
374
616
  # Flush any remaining scopes (triggers upload)
375
617
  @scope_batcher.flush
376
618
 
377
- @last_upload_time = Datadog::Core::Utils::Time.now
378
- @last_upload_scope_count = extracted_count
379
- rescue => e
619
+ @mutex.synchronize do
620
+ @last_upload_time = Datadog::Core::Utils::Time.now
621
+ @last_upload_scope_count = extracted_count
622
+ @last_upload_time_cv.broadcast
623
+ end
624
+ rescue Exception => e # standard:disable Lint/RescueException
625
+ Datadog::DI.reraise_if_fatal(e)
380
626
  @logger.debug { "symdb: extraction error: #{e.class}: #{e.message}" }
381
627
  @telemetry&.report(e, description: 'symdb: extraction error')
628
+ @mutex.synchronize do
629
+ @last_upload_time = Datadog::Core::Utils::Time.now
630
+ @last_upload_time_cv.broadcast
631
+ end
382
632
  ensure
383
633
  @mutex.synchronize do
384
634
  @upload_in_progress = false
@@ -387,19 +637,94 @@ module Datadog
387
637
  end
388
638
  end
389
639
 
640
+ # Drain the hot-load buffer, dedup by object_id, return the array of
641
+ # FILE scopes from per-module extraction.
642
+ # @return [Array<Scope>]
643
+ def extract_hot_load_buffer
644
+ modules = @hot_load_buffer_mutex.synchronize { @hot_load_buffer.shift(@hot_load_buffer.length) }
645
+ return [] if modules.empty?
646
+
647
+ seen = {}
648
+ modules.each { |mod| seen[mod.object_id] = mod }
649
+ seen.values.map { |mod| @extractor.extract(mod) }.compact
650
+ end
651
+
652
+ # Install the TracePoint :class hook (lazy — only on first start_upload).
653
+ # Hook fires for every class/module body open including reopens; pushes
654
+ # the module onto @hot_load_buffer and signals the scheduler. Singleton
655
+ # classes are filtered for the same reason as in Extractor#extract_all.
656
+ # Must be called from within @scheduler_mutex.synchronize.
657
+ # @return [void]
658
+ def install_hot_load_hook
659
+ return if @hot_load_tracepoint
660
+ component = self
661
+ logger = @logger
662
+ telemetry = @telemetry
663
+ @hot_load_tracepoint = TracePoint.new(:class) do |tp|
664
+ # The :class TracePoint fires inside the customer's class body —
665
+ # any exception that escapes this block surfaces at the customer's
666
+ # `class Foo; ... end` line and breaks their class load. The
667
+ # MODULE_SINGLETON_CLASS_PRED dispatch defends against one specific
668
+ # raise source (user-overridden singleton_class?); this rescue
669
+ # closes the general case. Verified: a raise inside the callback
670
+ # backtraces through `<class:CustomerClass>` in Ruby 3.x.
671
+
672
+ mod = tp.self
673
+ next if MODULE_SINGLETON_CLASS_PRED.bind(mod).call
674
+ component.send(:enqueue_hot_load, mod)
675
+ rescue Exception => e # standard:disable Lint/RescueException
676
+ Datadog::DI.reraise_if_fatal(e)
677
+ # Logger or telemetry can themselves raise (custom logger
678
+ # implementation, telemetry worker in an unexpected state). The
679
+ # :class TracePoint fires inside customer class bodies, so the
680
+ # error boundary must hold even when error reporting fails;
681
+ # nothing useful to do if logging is broken.
682
+ begin
683
+ logger.debug { "symdb: hot-load hook error: #{e.class}: #{e.message}" }
684
+ telemetry&.report(e, description: 'symdb: hot-load hook error')
685
+ rescue Exception => report_exc # standard:disable Lint/RescueException
686
+ Datadog::DI.reraise_if_fatal(report_exc)
687
+ nil
688
+ end
689
+ end
690
+ @hot_load_tracepoint.enable # steep:ignore NoMethod
691
+ end
692
+
693
+ # Enqueue a hot-loaded module and signal the scheduler.
694
+ # Called from the TracePoint :class block — must be cheap.
695
+ # @param mod [Module]
696
+ # @return [void]
697
+ def enqueue_hot_load(mod)
698
+ @hot_load_buffer_mutex.synchronize { @hot_load_buffer << mod }
699
+ @scheduler_mutex.synchronize do
700
+ return if @shutdown
701
+ # TracePoint#disable does not wait for in-flight callbacks: a :class
702
+ # event firing concurrently with stop_upload can reach here after the
703
+ # hook has been torn down. Without this guard the stale event would
704
+ # re-arm the scheduler, contradicting stop_upload's contract. The
705
+ # buffer push above is harmless — the next start_upload runs
706
+ # extract_all, which clears the buffer before extracting.
707
+ return unless @hot_load_tracepoint
708
+ @scheduled_at = Datadog::Core::Utils::Time.get_time + EXTRACT_DEBOUNCE_INTERVAL
709
+ @scheduler_signaled = true
710
+ @scheduler_cv.signal
711
+ end
712
+ end
713
+
390
714
  def log_scope_tree(scope, depth)
391
715
  indent = ' ' * depth
392
716
  @logger.trace { "symdb: #{indent}#{scope.scope_type} #{scope.name}" }
393
717
  scope.scopes&.each { |child| log_scope_tree(child, depth + 1) }
394
718
  end
395
719
 
396
- def count_targetable_methods(file_scopes)
720
+ # Count METHOD scopes with targetable lines inside one FILE scope. Used by
721
+ # extract_and_upload to accumulate the count while streaming, without
722
+ # retaining the Array<Scope> just to compute the total at the end.
723
+ def count_targetable_methods_in_scope(file_scope)
397
724
  count = 0
398
- file_scopes.each do |file_scope|
399
- file_scope.scopes&.each do |class_or_module|
400
- class_or_module.scopes&.each do |method_scope|
401
- count += 1 if method_scope.scope_type == 'METHOD' && method_scope.targetable_lines?
402
- end
725
+ file_scope.scopes&.each do |class_or_module|
726
+ class_or_module.scopes&.each do |method_scope|
727
+ count += 1 if method_scope.scope_type == 'METHOD' && method_scope.targetable_lines?
403
728
  end
404
729
  end
405
730
  count