datadog 2.36.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +6 -3
  5. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +17 -3
  6. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +219 -98
  7. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +2 -0
  8. data/ext/datadog_profiling_native_extension/extconf.rb +11 -0
  9. data/ext/datadog_profiling_native_extension/ruby_helpers.c +7 -0
  10. data/ext/libdatadog_api/di.c +76 -0
  11. data/ext/libdatadog_api/extconf.rb +6 -0
  12. data/ext/libdatadog_extconf_helpers.rb +1 -1
  13. data/lib/datadog/ai_guard/api_client.rb +6 -4
  14. data/lib/datadog/appsec/api_security/route_extractor.rb +7 -4
  15. data/lib/datadog/appsec/configuration.rb +6 -0
  16. data/lib/datadog/appsec/context.rb +4 -2
  17. data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
  18. data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
  19. data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +1 -1
  20. data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +4 -0
  21. data/lib/datadog/appsec/contrib/rack/input_peeker.rb +4 -17
  22. data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
  23. data/lib/datadog/appsec/contrib/rails/gateway/request.rb +2 -1
  24. data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
  25. data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +4 -0
  26. data/lib/datadog/appsec/ext.rb +1 -0
  27. data/lib/datadog/appsec/metrics/collector.rb +18 -1
  28. data/lib/datadog/appsec/metrics/exporter.rb +9 -3
  29. data/lib/datadog/appsec/remote.rb +9 -3
  30. data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
  31. data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
  32. data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
  33. data/lib/datadog/appsec/route_normalizer.rb +80 -0
  34. data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
  35. data/lib/datadog/core/configuration/components.rb +82 -2
  36. data/lib/datadog/core/configuration/components_state.rb +6 -1
  37. data/lib/datadog/core/configuration/settings.rb +0 -3
  38. data/lib/datadog/core/configuration/supported_configurations.rb +2 -0
  39. data/lib/datadog/core/evp.rb +11 -0
  40. data/lib/datadog/core/remote/client/capabilities.rb +34 -7
  41. data/lib/datadog/core/telemetry/event/app_started.rb +8 -1
  42. data/lib/datadog/di/base.rb +3 -1
  43. data/lib/datadog/di/code_tracker.rb +5 -2
  44. data/lib/datadog/di/component.rb +110 -36
  45. data/lib/datadog/di/error.rb +10 -0
  46. data/lib/datadog/di/fatal_exceptions.rb +26 -0
  47. data/lib/datadog/di/instrumenter.rb +381 -165
  48. data/lib/datadog/di/probe.rb +14 -0
  49. data/lib/datadog/di/probe_file_loader.rb +8 -3
  50. data/lib/datadog/di/probe_manager.rb +67 -9
  51. data/lib/datadog/di/probe_notification_builder.rb +4 -1
  52. data/lib/datadog/di/probe_notifier_worker.rb +52 -32
  53. data/lib/datadog/di/redactor.rb +16 -1
  54. data/lib/datadog/di/remote.rb +175 -14
  55. data/lib/datadog/di/serializer.rb +12 -5
  56. data/lib/datadog/di/transport/input.rb +8 -4
  57. data/lib/datadog/di.rb +63 -0
  58. data/lib/datadog/kit/appsec/events/v2.rb +60 -2
  59. data/lib/datadog/open_feature/component.rb +29 -6
  60. data/lib/datadog/open_feature/configuration.rb +8 -0
  61. data/lib/datadog/open_feature/ext.rb +2 -0
  62. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
  63. data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
  64. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
  65. data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
  66. data/lib/datadog/open_feature/native_evaluator.rb +22 -0
  67. data/lib/datadog/open_feature/provider.rb +59 -27
  68. data/lib/datadog/open_feature/transport.rb +55 -1
  69. data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
  70. data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
  71. data/lib/datadog/opentelemetry/sdk.rb +19 -0
  72. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
  73. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
  74. data/lib/datadog/symbol_database/component.rb +164 -47
  75. data/lib/datadog/symbol_database/configuration.rb +12 -4
  76. data/lib/datadog/symbol_database/extensions.rb +19 -0
  77. data/lib/datadog/symbol_database/extractor.rb +124 -87
  78. data/lib/datadog/symbol_database/file_hash.rb +3 -1
  79. data/lib/datadog/symbol_database/remote.rb +8 -3
  80. data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
  81. data/lib/datadog/symbol_database/uploader.rb +9 -2
  82. data/lib/datadog/symbol_database.rb +22 -0
  83. data/lib/datadog/tracing/distributed/baggage.rb +2 -1
  84. data/lib/datadog/tracing/ext.rb +9 -0
  85. data/lib/datadog/tracing/remote.rb +34 -3
  86. data/lib/datadog/version.rb +1 -1
  87. data/lib/datadog.rb +1 -0
  88. metadata +19 -6
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_text'
4
+
5
+ module Datadog
6
+ module AppSec
7
+ module RouteNormalizer
8
+ # Normalizes a route spec pattern into the route format, inspired by
9
+ # OpenAPI v3 path templating (best effort)
10
+ #
11
+ # Example:
12
+ #
13
+ # /users/:id => /users/{id}
14
+ # /photos/:id.:format => /photos/{id+format}
15
+ # /posts/:id(.:format) => /posts/{id+format}
16
+ # /files/*path => /files/{path}
17
+ # /hello world => /hello%20world
18
+ #
19
+ # NOTE: When a request path is supplied, optional groups `(...)` are resolved
20
+ # against it: a group is kept only when the path actually matched it.
21
+ #
22
+ # NOTE: Without a request path (or for paths longer than {MAX_RESOLVE_LENGTH}),
23
+ # optional markers are flattened and kept as if present.
24
+ #
25
+ # @api private
26
+ class RoutePattern
27
+ GROUP_OPEN_CHAR = '('
28
+ GROUP_CLOSE_CHAR = ')'
29
+
30
+ OPTIONAL_GROUP_SUFFIX_CHAR = '?'
31
+ OPTIONAL_GROUP_SIGILS = [
32
+ GROUP_OPEN_CHAR,
33
+ GROUP_CLOSE_CHAR,
34
+ OPTIONAL_GROUP_SUFFIX_CHAR
35
+ ].join
36
+
37
+ NAMED_PARAM_PREFIX_CHAR = ':'
38
+ GLOB_PARAM_PREFIX_CHAR = '*'
39
+
40
+ PATTERN_STRUCTURE_CHARS = [
41
+ NAMED_PARAM_PREFIX_CHAR,
42
+ GLOB_PARAM_PREFIX_CHAR,
43
+ GROUP_OPEN_CHAR,
44
+ GROUP_CLOSE_CHAR
45
+ ].join
46
+
47
+ PARAM_NAME_CHARS = [*'a'..'z', *'A'..'Z', *'0'..'9', '_'].join
48
+
49
+ EXCLUDED_PARAM_NAME_TERMINATOR_CHARS = [
50
+ PARAM_NAME_CHARS,
51
+ PATTERN_STRUCTURE_CHARS
52
+ ].join
53
+
54
+ MAX_RESOLVE_LENGTH = 8192
55
+
56
+ Checkpoint = Struct.new(:resolved_length, :pattern_index, :path_index)
57
+
58
+ # Param sigils mark where dynamic route syntax may start
59
+ #
60
+ # Example:
61
+ #
62
+ # users -> no
63
+ # :id -> yes
64
+ # user-:id -> yes
65
+ # * -> yes
66
+ # foo: -> yes (validated later by {PARAM_TOKENS})
67
+ PARAM_START_SIGILS = /[:\*]/
68
+ PARAM_TOKENS = /:\w+|(?<!\w)\*\w*/
69
+
70
+ def initialize(pattern)
71
+ @pattern = pattern
72
+ end
73
+
74
+ def normalize(path: nil)
75
+ @path_index = 0
76
+ @pattern_index = 0
77
+
78
+ @path = path || ''
79
+ @path_length = @path.length
80
+ @pattern_length = @pattern.length
81
+
82
+ nameless_counter = 0
83
+ pattern = resolve_pattern_optionals(path)
84
+
85
+ result = pattern.split('/', -1).each_with_object(+'') do |segment, memo|
86
+ memo << '/' unless memo.empty? && segment.empty?
87
+ next if segment.empty?
88
+
89
+ unless segment.match?(PARAM_START_SIGILS)
90
+ memo << RouteText.escape(segment)
91
+ next
92
+ end
93
+
94
+ tokens = segment.scan(PARAM_TOKENS)
95
+
96
+ if tokens.empty?
97
+ memo << RouteText.escape(segment)
98
+ next
99
+ end
100
+
101
+ names = tokens.map do |token|
102
+ if token.length > 1
103
+ token[1..-1]
104
+ else
105
+ "param#{nameless_counter += 1}"
106
+ end
107
+ end
108
+
109
+ memo << "{#{names.join('+')}}"
110
+ end
111
+
112
+ result.start_with?('/') ? result : "/#{result}"
113
+ end
114
+
115
+ private
116
+
117
+ # Optional resolution walks the route pattern and request path together
118
+ #
119
+ # Example:
120
+ #
121
+ # pattern: /posts/:id(.:format)
122
+ # path: /posts/1.json
123
+ # ^
124
+ # `.` matches, so the optional group is kept
125
+ #
126
+ # pattern: /posts/:id(.:format)
127
+ # path: /posts/1
128
+ # ^
129
+ # path ended before `.`, so the optional group is skipped
130
+ #
131
+ # Checkpoints let optional groups fail after a partial match
132
+ #
133
+ # pattern: /posts(/:id)/edit
134
+ # path: /posts/edit
135
+ # ^
136
+ # `(/:id)` starts like `/edit`, so a checkpoint is saved
137
+ #
138
+ # pattern: /posts(/:id)/edit
139
+ # path: /posts/edit
140
+ # ^
141
+ # `:id` cannot consume `edit`, so we rewind after `(/:id)`
142
+ def resolve_pattern_optionals(path)
143
+ return @pattern if !@pattern.include?(GROUP_OPEN_CHAR) && !@pattern.include?(OPTIONAL_GROUP_SUFFIX_CHAR)
144
+
145
+ unless resolve_pattern_optionals?(path)
146
+ return remove_optional_group_sigils(@pattern) if @pattern.include?(GROUP_OPEN_CHAR)
147
+
148
+ return @pattern
149
+ end
150
+
151
+ resolved = +''
152
+ checkpoints = []
153
+
154
+ while @pattern_index < @pattern_length
155
+ char = @pattern[@pattern_index] # : String
156
+
157
+ case char
158
+ when GROUP_OPEN_CHAR
159
+ optional_group_end_index = find_next_optional_group_end_index
160
+
161
+ # NOTE: The request path may share the optional group's first char
162
+ # with the next required segment
163
+ #
164
+ # Example: `/posts/edit` request path starts like `(/:id)` in pattern
165
+ # `/posts(/:id)/edit`, but `:id` must be skipped
166
+ if @path_index < @path_length && @path[@path_index] == @pattern[@pattern_index + 1]
167
+ checkpoints << Checkpoint.new(resolved.length, optional_group_end_index + 1, @path_index)
168
+ @pattern_index += 1
169
+ else
170
+ @pattern_index = optional_group_end_index + 1
171
+ end
172
+ when GROUP_CLOSE_CHAR
173
+ @pattern_index += 1
174
+ @pattern_index += 1 if @pattern[@pattern_index] == OPTIONAL_GROUP_SUFFIX_CHAR
175
+ when OPTIONAL_GROUP_SUFFIX_CHAR
176
+ @pattern_index += 1
177
+ when NAMED_PARAM_PREFIX_CHAR
178
+ param_name_end_index = find_param_name_end_index(@pattern_index)
179
+
180
+ # NOTE: A ':' without a param name is literal text
181
+ # Example: `/foo:` must match a literal `:`, not a param
182
+ if param_name_end_index == @pattern_index + 1
183
+ unless expected_path_char?(NAMED_PARAM_PREFIX_CHAR)
184
+ checkpoint = restore_checkpoint!(resolved, checkpoints)
185
+ return remove_optional_group_sigils(@pattern) unless checkpoint
186
+
187
+ @pattern_index = checkpoint.pattern_index
188
+ @path_index = checkpoint.path_index
189
+
190
+ next
191
+ end
192
+
193
+ @path_index += 1
194
+ @pattern_index += 1
195
+ resolved << NAMED_PARAM_PREFIX_CHAR
196
+
197
+ next
198
+ end
199
+
200
+ # NOTE: A trailing `?` makes the param optional
201
+ # Example: `/posts/:id.?:format?` with `/posts/1` skips `:format?`
202
+ if @pattern[param_name_end_index] == OPTIONAL_GROUP_SUFFIX_CHAR && @path_index >= @path_length
203
+ @pattern_index = param_name_end_index + 1
204
+ next
205
+ end
206
+
207
+ param_token = @pattern[@pattern_index...param_name_end_index] # : String
208
+ resolved << param_token
209
+
210
+ terminator_char = find_param_value_terminator_char(param_name_end_index: param_name_end_index)
211
+ @path_index = find_param_value_end_index(terminator_char: terminator_char)
212
+
213
+ @pattern_index = param_name_end_index
214
+ when GLOB_PARAM_PREFIX_CHAR
215
+ param_name_end_index = find_param_name_end_index(@pattern_index)
216
+ terminator_char = find_param_value_terminator_char(param_name_end_index: param_name_end_index)
217
+
218
+ param_token = @pattern[@pattern_index...param_name_end_index] # : String
219
+ resolved << param_token
220
+
221
+ @path_index = find_glob_value_end_index(terminator_char: terminator_char)
222
+ @pattern_index = param_name_end_index
223
+ else
224
+ unless expected_path_char?(char)
225
+ if @pattern[@pattern_index + 1] == OPTIONAL_GROUP_SUFFIX_CHAR
226
+ @pattern_index += 2
227
+ next
228
+ end
229
+
230
+ next if skip_char_with_optional_param?
231
+
232
+ checkpoint = restore_checkpoint!(resolved, checkpoints)
233
+ return remove_optional_group_sigils(@pattern) unless checkpoint
234
+
235
+ @pattern_index = checkpoint.pattern_index
236
+ @path_index = checkpoint.path_index
237
+
238
+ next
239
+ end
240
+
241
+ resolved << char
242
+
243
+ @path_index += 1
244
+ @pattern_index += 1
245
+ end
246
+ end
247
+
248
+ resolved
249
+ end
250
+
251
+ def resolve_pattern_optionals?(path)
252
+ return false unless path
253
+
254
+ @path_length <= MAX_RESOLVE_LENGTH
255
+ end
256
+
257
+ def expected_path_char?(char)
258
+ @path_index < @path_length && @path[@path_index] == char
259
+ end
260
+
261
+ def restore_checkpoint!(resolved, checkpoints)
262
+ checkpoint = checkpoints.pop
263
+ return unless checkpoint
264
+
265
+ resolved.slice!(checkpoint.resolved_length..-1)
266
+ checkpoint
267
+ end
268
+
269
+ def remove_optional_group_sigils(pattern)
270
+ pattern.delete(OPTIONAL_GROUP_SIGILS)
271
+ end
272
+
273
+ # NOTE: When the path ends before a char that prefixes an optional param,
274
+ # skip both the char and that param
275
+ #
276
+ # Example:
277
+ # pattern: /posts/:id?
278
+ # path: /posts
279
+ # skip: ^^^^^
280
+ #
281
+ # pattern: /a.:b?
282
+ # path: /a
283
+ # skip: ^^^^
284
+ def skip_char_with_optional_param?
285
+ return false if @path_index < @path_length
286
+
287
+ param_start_index = @pattern_index + 1
288
+ return false unless param_start_char?(param_start_index)
289
+
290
+ param_name_end_index = find_param_name_end_index(param_start_index)
291
+ return false unless @pattern[param_name_end_index] == OPTIONAL_GROUP_SUFFIX_CHAR
292
+
293
+ @pattern_index = param_name_end_index + 1
294
+ true
295
+ end
296
+
297
+ def param_start_char?(index)
298
+ @pattern[index] == NAMED_PARAM_PREFIX_CHAR || @pattern[index] == GLOB_PARAM_PREFIX_CHAR
299
+ end
300
+
301
+ def find_param_name_end_index(param_start_index)
302
+ current_index = param_start_index + 1
303
+
304
+ while current_index < @pattern_length
305
+ char = @pattern[current_index] # : String
306
+ break unless PARAM_NAME_CHARS.include?(char)
307
+
308
+ current_index += 1
309
+ end
310
+
311
+ current_index
312
+ end
313
+
314
+ def find_param_value_terminator_char(param_name_end_index:)
315
+ char = @pattern[param_name_end_index]
316
+
317
+ if char == GROUP_OPEN_CHAR
318
+ @pattern[param_name_end_index + 1]
319
+ else
320
+ char
321
+ end
322
+ end
323
+
324
+ def find_param_value_end_index(terminator_char:)
325
+ terminator_indexes = []
326
+ slash_index = @path.index('/', @path_index)
327
+ dot_index = @path.index('.', @path_index)
328
+
329
+ terminator_indexes << slash_index if slash_index
330
+ terminator_indexes << dot_index if dot_index
331
+
332
+ if terminator_char && !EXCLUDED_PARAM_NAME_TERMINATOR_CHARS.include?(terminator_char)
333
+ custom_index = @path.index(terminator_char, @path_index)
334
+ terminator_indexes << custom_index if custom_index
335
+ end
336
+
337
+ terminator_indexes.empty? ? @path.length : terminator_indexes.min
338
+ end
339
+
340
+ def find_glob_value_end_index(terminator_char:)
341
+ return @path_length unless terminator_char
342
+ return @path_length if EXCLUDED_PARAM_NAME_TERMINATOR_CHARS.include?(terminator_char)
343
+
344
+ terminator_index = @path.rindex(terminator_char)
345
+ return @path_length unless terminator_index && terminator_index >= @path_index
346
+
347
+ terminator_index
348
+ end
349
+
350
+ def find_next_optional_group_end_index
351
+ depth = 0
352
+ index = @pattern_index
353
+
354
+ while index < @pattern_length
355
+ char = @pattern[index]
356
+ depth += 1 if char == GROUP_OPEN_CHAR
357
+
358
+ if char == GROUP_CLOSE_CHAR
359
+ depth -= 1
360
+
361
+ if depth.zero?
362
+ next_index = index + 1
363
+
364
+ return next_index if @pattern[next_index] == OPTIONAL_GROUP_SUFFIX_CHAR
365
+
366
+ return index
367
+ end
368
+ end
369
+
370
+ index += 1
371
+ end
372
+
373
+ index
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module AppSec
5
+ module RouteNormalizer
6
+ # Percent-encodes route text, leaving param templates untouched
7
+ #
8
+ # @api private
9
+ module RouteText
10
+ DISALLOWED_CHARS = %r{[^\w.~/-]}
11
+
12
+ # Per-byte percent-encoding lookup, indexed by byte value (0-255)
13
+ #
14
+ # Example:
15
+ #
16
+ # 32 => %20 (space)
17
+ # 33 => %21 (!)
18
+ # 47 => / (passthrough)
19
+ # 65 => A (passthrough)
20
+ # 195 => %C3 (UTF-8 lead byte)
21
+ # 169 => %A9 (UTF-8 continuation byte)
22
+ BYTE_ENCODING_TABLE = Array.new(256) do |byte|
23
+ char = byte.chr
24
+ char.match?(DISALLOWED_CHARS) ? -('%%%02X' % byte) : -char
25
+ end.freeze
26
+
27
+ # Max bytes one input byte expands to as percent-encoded `%XX`
28
+ MAX_ENCODED_BYTE_SIZE = 3
29
+
30
+ module_function
31
+
32
+ # Escapes literal route text into normalized-route form
33
+ #
34
+ # Example:
35
+ #
36
+ # a+b => a%2Bb
37
+ # café => caf%C3%A9
38
+ # /users/path => /users/path
39
+ #
40
+ # NOTE: {URI::Parser#escape} with {DISALLOWED_CHARS} gives the same output,
41
+ # but the generic gsub/sprintf path is slower and allocates more per request
42
+ def escape(text)
43
+ return text unless text.match?(DISALLOWED_CHARS)
44
+
45
+ buffer = String.new(capacity: text.bytesize * MAX_ENCODED_BYTE_SIZE, encoding: Encoding::UTF_8)
46
+ text.each_byte { |byte| buffer << BYTE_ENCODING_TABLE.fetch(byte) }
47
+ buffer
48
+ # NOTE: Defensive only — this can never happen. {String#each_byte} yields
49
+ # integers in 0-255 and {BYTE_ENCODING_TABLE} has an entry for every one
50
+ rescue IndexError => e
51
+ AppSec.telemetry&.report(e, description: 'AppSec: Route text byte outside 0-255 escape table')
52
+ '~invalid~'
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'route_normalizer/route_pattern'
4
+ require_relative 'route_normalizer/rails_route_pattern'
5
+
6
+ module Datadog
7
+ module AppSec
8
+ # Extracts framework route data from Rack env and normalizes it into
9
+ # OpenAPI v3 compatible route format
10
+ #
11
+ # @api private
12
+ module RouteNormalizer
13
+ GRAPE_ROUTE_KEY = 'grape.routing_args'
14
+ SINATRA_ROUTE_KEY = 'sinatra.route'
15
+ DATADOG_ROUTE_KEY = 'datadog.action_dispatch.route'
16
+ RAILS_ROUTE_KEY = 'action_dispatch.route'
17
+ RAILS_ROUTE_URI_PATTERN_KEY = 'action_dispatch.route_uri_pattern'
18
+ RAILS_PATH_PARAMS_KEY = 'action_dispatch.request.path_parameters'
19
+
20
+ module_function
21
+
22
+ # Extracts a normalized route path for a Rack request.
23
+ #
24
+ # Examples:
25
+ #
26
+ # Grape: "/api/users/:id" => "/api/users/{id}"
27
+ # Sinatra: "GET /download/*.*" => "/download/{param1+param2}"
28
+ # Rails: "/posts/:id(.:format)" + path => "/posts/{id+format}"
29
+ # Pattern: "/users/:id" => "/users/{id}"
30
+ def extract_normalized_route(rack_env, prefix: nil, pattern: nil)
31
+ route_prefix = prefix || rack_env['SCRIPT_NAME']
32
+
33
+ if rack_env.key?(GRAPE_ROUTE_KEY)
34
+ route_info = rack_env[GRAPE_ROUTE_KEY][:route_info]
35
+ normalize_route_string(route_info&.pattern&.origin)
36
+ elsif rack_env.key?(SINATRA_ROUTE_KEY)
37
+ normalize_route_string(rack_env[SINATRA_ROUTE_KEY].split(' ', 2)[1])
38
+ elsif (rails_route = rack_env[DATADOG_ROUTE_KEY] || rack_env[RAILS_ROUTE_KEY])
39
+ normalize_rails_route(rails_route, rack_env, route_prefix)
40
+ elsif rack_env.key?(RAILS_ROUTE_URI_PATTERN_KEY)
41
+ normalize_rails_route(rack_env[RAILS_ROUTE_URI_PATTERN_KEY], rack_env, route_prefix)
42
+ else
43
+ normalize_route_string(pattern)
44
+ end
45
+ rescue => e
46
+ AppSec.telemetry&.report(e, description: 'AppSec: Could not compute normalized route')
47
+
48
+ nil
49
+ end
50
+
51
+ private_class_method def normalize_rails_route(route, rack_env, route_prefix)
52
+ path_params = rack_env.fetch(RAILS_PATH_PARAMS_KEY, {})
53
+ path = path_without_prefix(rack_env['PATH_INFO'].to_s, route_prefix)
54
+
55
+ RailsRoutePattern.new(route).normalize(path_params: path_params, path: path)
56
+ end
57
+
58
+ private_class_method def normalize_route_string(route)
59
+ return unless route
60
+
61
+ RoutePattern.new(route).normalize
62
+ end
63
+
64
+ private_class_method def path_without_prefix(path, prefix)
65
+ return path unless prefix
66
+
67
+ prefix = prefix.to_s
68
+ return path if prefix.empty? || prefix == '/'
69
+ return path unless path.start_with?(prefix)
70
+
71
+ next_char = path[prefix.length]
72
+ return path if next_char && next_char != '/'
73
+
74
+ # NOTE: `start_with?` guards impossible `nil` case
75
+ stripped = path[prefix.length..-1] # : String
76
+ stripped.empty? ? '/' : stripped
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module AppSec
5
+ module Utils
6
+ module HTTP
7
+ module BodyReader
8
+ module_function
9
+
10
+ def read(body, limit:, rewind_before_read: false)
11
+ return body.byteslice(0, limit + 1) if body.is_a?(String)
12
+ return if body.nil? || !body.respond_to?(:read)
13
+ return if rewind_before_read && !body.respond_to?(:rewind)
14
+
15
+ read_stream(body, limit: limit, rewind_before_read: rewind_before_read)
16
+ end
17
+
18
+ def read_stream(body, limit:, rewind_before_read: false)
19
+ # @type var rewound: bool
20
+ rewound = false
21
+
22
+ if rewind_before_read
23
+ return unless rewind(body)
24
+
25
+ rewound = true
26
+ end
27
+
28
+ buffer = +''.b
29
+ max = limit + 1
30
+
31
+ while buffer.bytesize <= limit
32
+ chunk = body.read(max - buffer.bytesize)
33
+ break if chunk.nil? || chunk.empty?
34
+
35
+ buffer << chunk
36
+ end
37
+
38
+ buffer
39
+ rescue => e
40
+ Datadog.logger.debug { "AppSec: Failed to read body: #{e.class}: #{e.message}" }
41
+
42
+ raise
43
+ ensure
44
+ rewind(body) if rewound
45
+ end
46
+
47
+ private_class_method def rewind(body)
48
+ return false unless body.respond_to?(:rewind)
49
+
50
+ # NOTE: Steep can't narrow interfaces via `respond_to?` guard-clause
51
+ body.rewind # steep:ignore
52
+ true
53
+ rescue => e
54
+ Datadog.logger.debug { "AppSec: Failed to rewind body: #{e.class}: #{e.message}" }
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -46,6 +46,38 @@ module Datadog
46
46
  Core::Diagnostics::Health::Metrics.new(telemetry: telemetry, logger: logger, **options)
47
47
  end
48
48
 
49
+ # Decides whether to build the SymbolDatabase component, for the
50
+ # tri-state symbol_database.enabled setting: true/false are explicit
51
+ # overrides; nil (the default) mirrors Dynamic Instrumentation by
52
+ # building whenever DI's component was built. dynamic_instrumentation
53
+ # is DI's build result — nil only when DI is explicitly disabled or the
54
+ # runtime can't support it (Rails development mode, missing C
55
+ # extension, remote config off, non-MRI). Under the always-build model
56
+ # it is non-nil even when the DI setting defaults to off, so a built
57
+ # SymbolDatabase component is inert until an upload is actually
58
+ # permitted: the component's own gate (see Component#upload_allowed?)
59
+ # only extracts and uploads when DI is truly active or the customer
60
+ # opted in explicitly. This mirrors DI advertising/building a component
61
+ # by default while installing no probes until it is enabled.
62
+ # force_upload (internal/testing) always builds, since it uploads
63
+ # regardless of DI or the enabled setting.
64
+ # @param settings [Configuration::Settings]
65
+ # @param dynamic_instrumentation [DI::Component, nil]
66
+ # @return [Boolean]
67
+ def enable_symbol_database?(settings, dynamic_instrumentation)
68
+ # The symbol_database settings group is only registered on the full
69
+ # library load path; a partial load (e.g. require 'datadog/di') leaves
70
+ # it absent, in which case the feature cannot be enabled.
71
+ return false unless settings.respond_to?(:symbol_database)
72
+
73
+ # force_upload uploads unconditionally, so the component must be built
74
+ # even when the setting is nil and DI's component was not built.
75
+ return true if settings.symbol_database.internal.force_upload
76
+
77
+ # nil (the default) follows whether DI's component was built.
78
+ Datadog::SymbolDatabase.resolve_enabled(settings.symbol_database.enabled, !dynamic_instrumentation.nil?)
79
+ end
80
+
49
81
  def build_logger(settings)
50
82
  logger = settings.logger.instance || Core::Logger.new($stderr)
51
83
  logger.level = settings.diagnostics.debug ? ::Logger::DEBUG : settings.logger.level
@@ -174,10 +206,31 @@ module Datadog
174
206
  @ai_guard = Datadog::AIGuard::Component.build(settings, logger: @logger, telemetry: telemetry)
175
207
  @open_feature = OpenFeature::Component.build(settings, agent_settings, logger: @logger, telemetry: telemetry)
176
208
  @dynamic_instrumentation = Datadog::DI::Component.build(settings, agent_settings, @logger, telemetry: telemetry)
177
- @symbol_database = Datadog::SymbolDatabase::Component.build(settings, agent_settings, @logger, telemetry: telemetry)
209
+ # Only build symbol database when enabled, so a disabled component is
210
+ # never constructed.
211
+ @symbol_database =
212
+ if self.class.enable_symbol_database?(settings, @dynamic_instrumentation)
213
+ # di_active is a live predicate, not a snapshot: DI may start after
214
+ # this component is built (implicit enablement via remote config).
215
+ # Symbol Database holds only this opaque proc, never a DI reference,
216
+ # so the two modules stay independent — the orchestration layer
217
+ # owns the cross-feature knowledge.
218
+ Datadog::SymbolDatabase::Component.build(
219
+ settings, agent_settings, @logger,
220
+ telemetry: telemetry,
221
+ di_active: -> { dynamic_instrumentation&.started? || false },
222
+ )
223
+ end
178
224
  @error_tracking = Datadog::ErrorTracking::Component.build(settings, @tracer, @logger)
179
225
  @data_streams = self.class.build_data_streams(settings, agent_settings, @logger, @agent_info)
180
- @environment_logger_extra[:dynamic_instrumentation_enabled] = !!@dynamic_instrumentation
226
+ # Reflects "the customer configured DI to be on" — true iff the
227
+ # component was built AND the env-var-driven enabled flag is set.
228
+ # This is the post-initialize / pre-startup value visible to tests
229
+ # that don't call startup!. Production overwrites this in startup!
230
+ # below with `dynamic_instrumentation&.started?`, which also accounts
231
+ # for RC-driven enablement carried over via ComponentsState.
232
+ @environment_logger_extra[:dynamic_instrumentation_enabled] =
233
+ !!(@dynamic_instrumentation && settings.dynamic_instrumentation.enabled)
181
234
 
182
235
  # Configure non-privileged components.
183
236
  Datadog::Tracing::Contrib::Component.configure(settings)
@@ -228,11 +281,25 @@ module Datadog
228
281
  remote.start
229
282
  end
230
283
 
284
+ # Start DI component if enabled by env var or if it was implicitly enabled
285
+ # (via RC) in the previous Components instance. dynamic_instrumentation is
286
+ # non-nil only when Component.build's preconditions passed (Ruby 2.6+ MRI,
287
+ # etc.), which is exactly the condition under which di/base.rb is loaded
288
+ # and DI.activate_tracking is defined.
289
+ if dynamic_instrumentation
290
+ if settings.dynamic_instrumentation.enabled || old_state&.di_implicitly_enabled?
291
+ DI.activate_tracking
292
+ dynamic_instrumentation.start!
293
+ end
294
+ end
295
+
231
296
  # This should stay here, not in initialize. During reconfiguration, the order of the calls is:
232
297
  # initialize new components, shutdown old components, startup new components.
233
298
  # Because this is a singleton, if we call it in initialize, it will be shutdown right away.
234
299
  Core::ProcessDiscovery.publish(settings)
235
300
 
301
+ @environment_logger_extra[:dynamic_instrumentation_enabled] = dynamic_instrumentation&.started? || false
302
+
236
303
  Core::Diagnostics::EnvironmentLogger.collect_and_log!(@environment_logger_extra)
237
304
  end
238
305
 
@@ -309,9 +376,22 @@ module Datadog
309
376
 
310
377
  # Returns the current state of various components.
311
378
  def state
379
+ # di_implicitly_enabled distinguishes RC-driven start from explicit
380
+ # start (env var or programmatic) so that an explicit
381
+ # `enabled = false` on reconfiguration can take effect.
382
+ #
383
+ # using_default? — not `!enabled` — because Datadog.configure has
384
+ # already mutated the singleton settings by the time #state runs,
385
+ # so `!enabled` would treat a customer's explicit
386
+ # `enabled = false` as "implicitly enabled" and restart DI.
387
+ # using_default? asks "did the customer ever touch this setting",
388
+ # which is the right question for RC carry-over.
389
+ di_implicit = dynamic_instrumentation&.started? &&
390
+ @settings.dynamic_instrumentation.using_default?(:enabled)
312
391
  ComponentsState.new(
313
392
  telemetry_enabled: telemetry.enabled,
314
393
  remote_started: remote&.started?,
394
+ di_implicitly_enabled: di_implicit || false,
315
395
  )
316
396
  end
317
397
  end