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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'aggregator'
4
+ require_relative '../../core/encoding'
5
+ require_relative '../../core/evp'
6
+ require_relative '../../core/utils/time'
7
+ require_relative '../../core/workers/async'
8
+
9
+ module Datadog
10
+ module OpenFeature
11
+ module FlagEvaluation
12
+ # Background writer that drains the two-tier aggregation maps and POSTs
13
+ # batches to /evp_proxy/v2/api/v2/flagevaluation every FLUSH_INTERVAL_SECONDS.
14
+ #
15
+ # The writer owns the aggregation cycle:
16
+ # 1. Hook calls enqueue (non-blocking) — never aggregates inline.
17
+ # 2. Background thread wakes, calls aggregator.record for each enqueued event, flushes.
18
+ # 3. flush_once drains aggregation maps, builds payload, sends via transport.
19
+ #
20
+ # Thread model: MRI Ruby GIL — Mutex + ConditionVariable + SizedQueue + Thread.
21
+ # The flush loop waits on a ConditionVariable (interruptible) rather than a bare
22
+ # sleep, so #stop can wake the worker immediately and still drain + final-flush.
23
+ class Writer
24
+ include Core::Workers::Async::Thread
25
+
26
+ FLUSH_INTERVAL_SECONDS = 10
27
+ DRAIN_INTERVAL_SECONDS = 0.1
28
+ SHUTDOWN_TIMEOUT_SECONDS = 5
29
+ QUEUE_SIZE = 4_096
30
+ MAX_DRAIN_EVENTS_PER_CYCLE = 1_024
31
+ PAYLOAD_SIZE_LIMIT_BYTES = Core::EVP::PAYLOAD_SIZE_LIMIT_BYTES
32
+ TELEMETRY_NAMESPACE = 'tracers'
33
+ ROWS_DROPPED_METRIC = 'flagevaluation.rows.dropped'
34
+ ROWS_DEGRADED_METRIC = 'flagevaluation.rows.degraded'
35
+ PAYLOAD_SPLITS_METRIC = 'flagevaluation.payload.splits'
36
+
37
+ REASON_QUEUE_OVERFLOW = 'queue_overflow'
38
+ REASON_DEGRADED_CAP = 'degraded_cap'
39
+ REASON_CARDINALITY_CAP = 'cardinality_cap'
40
+ REASON_PAYLOAD_LIMIT = 'payload_limit'
41
+
42
+ # Service context fields for the batch wrapper.
43
+ attr_reader :service_context
44
+
45
+ # Observable count of events dropped because the async hand-off queue was full.
46
+ # Reset to 0 each flush after being emitted, mirroring the aggregator's overflow counter.
47
+ attr_reader :dropped_queue_overflow
48
+
49
+ def initialize(transport:, logger:, telemetry: nil)
50
+ @transport = transport
51
+ @logger = logger
52
+ @telemetry = telemetry
53
+ @aggregator = Aggregator.new
54
+ @queue = SizedQueue.new(QUEUE_SIZE)
55
+ @stop_mutex = Mutex.new
56
+ @stop_cond = ConditionVariable.new
57
+ @stopped = false
58
+ @dropped_queue_overflow = 0
59
+
60
+ self.fork_policy = Core::Workers::Async::Thread::FORK_POLICY_RESTART
61
+
62
+ @service_context = build_service_context
63
+ start_background_thread
64
+ end
65
+
66
+ # Non-blocking enqueue from the finally hook. Drops + counts on overflow.
67
+ # Context flattening/pruning runs in the background writer, not on the caller eval thread.
68
+ def enqueue(**event)
69
+ start_background_thread if forked?
70
+
71
+ attrs = event[:attrs]
72
+ attrs = attrs.is_a?(Hash) ? snapshot_context_value(attrs, {}, 0) || {} : {}
73
+ bounded_event = {
74
+ flag_key: event[:flag_key],
75
+ variant: event[:variant],
76
+ allocation_key: event[:allocation_key],
77
+ error_message: event[:error_message],
78
+ runtime_default: event[:runtime_default],
79
+ targeting_key: event[:targeting_key],
80
+ eval_time_ms: event[:eval_time_ms],
81
+ attrs: attrs,
82
+ }
83
+ @queue.push(bounded_event, true)
84
+ start_background_thread unless running?
85
+ rescue ThreadError
86
+ # Queue full — drop and count (best-effort, same as Go: drop-and-count). The count is
87
+ # emitted on the next flush so backpressure is observable, not silently lost.
88
+ @stop_mutex.synchronize { @dropped_queue_overflow += 1 }
89
+ end
90
+
91
+ # Stop the background thread and flush remaining events. Wakes the worker out of its
92
+ # interruptible wait so the drain + final flush happen immediately (no up-to-10s delay).
93
+ def stop
94
+ @stop_mutex.synchronize do
95
+ @stopped = true
96
+ @stop_cond.broadcast
97
+ end
98
+
99
+ return true if join(SHUTDOWN_TIMEOUT_SECONDS)
100
+
101
+ @logger.debug { 'OpenFeature EVP: writer did not stop gracefully; terminating worker thread' }
102
+ terminate
103
+ end
104
+
105
+ protected
106
+
107
+ def after_fork
108
+ @aggregator = Aggregator.new
109
+ @queue = SizedQueue.new(QUEUE_SIZE)
110
+ @stop_mutex = Mutex.new
111
+ @stop_cond = ConditionVariable.new
112
+ @stopped = false
113
+ @dropped_queue_overflow = 0
114
+ end
115
+
116
+ private
117
+
118
+ def build_service_context
119
+ config = Datadog.configuration
120
+ ctx = {'service' => config.service.to_s}
121
+ ctx['env'] = config.env if config.env && !config.env.empty?
122
+ ctx['version'] = config.version if config.version && !config.version.empty?
123
+ ctx
124
+ end
125
+
126
+ def snapshot_context_value(value, seen, depth)
127
+ return if depth > Aggregator::MAX_CONTEXT_DEPTH
128
+
129
+ case value
130
+ when Hash
131
+ object_id = value.object_id
132
+ return if seen[object_id]
133
+
134
+ seen[object_id] = true
135
+ value.each_with_object({}) do |(key, child_value), snapshot|
136
+ snapshot[key.is_a?(String) ? key.dup : key] = snapshot_context_value(child_value, seen, depth + 1)
137
+ end.tap { seen.delete(object_id) }
138
+ when Array
139
+ object_id = value.object_id
140
+ return if seen[object_id]
141
+
142
+ seen[object_id] = true
143
+ value.map { |child_value| snapshot_context_value(child_value, seen, depth + 1) }.tap { seen.delete(object_id) }
144
+ when String
145
+ value.dup
146
+ else
147
+ begin
148
+ value.dup
149
+ rescue TypeError
150
+ value
151
+ end
152
+ end
153
+ end
154
+
155
+ def start_background_thread
156
+ perform
157
+ end
158
+
159
+ def perform
160
+ last_flush = Core::Utils::Time.get_time
161
+
162
+ loop do
163
+ wait_for_next_cycle
164
+ begin
165
+ drain_queue
166
+ now = Core::Utils::Time.get_time
167
+ if stopped? || now - last_flush >= FLUSH_INTERVAL_SECONDS
168
+ flush_once
169
+ last_flush = now
170
+ end
171
+ rescue => e
172
+ @logger.debug { "OpenFeature EVP: writer error: #{e.class}: #{e.message}" }
173
+ end
174
+
175
+ break if stopped?
176
+ end
177
+
178
+ # Final drain + flush on shutdown so queued events are not lost.
179
+ begin
180
+ drain_and_flush
181
+ rescue => e
182
+ @logger.debug { "OpenFeature EVP: writer final-flush error: #{e.class}: #{e.message}" }
183
+ end
184
+ end
185
+
186
+ def stopped?
187
+ @stop_mutex.synchronize { @stopped }
188
+ end
189
+
190
+ def wait_for_next_cycle
191
+ @stop_mutex.synchronize do
192
+ return if @stopped
193
+
194
+ @stop_cond.wait(@stop_mutex, DRAIN_INTERVAL_SECONDS)
195
+ end
196
+ end
197
+
198
+ def drain_and_flush
199
+ drain_queue(max_events: nil)
200
+ flush_once
201
+ end
202
+
203
+ def drain_queue(max_events: MAX_DRAIN_EVENTS_PER_CYCLE)
204
+ # Drain async queue into aggregator (background thread only).
205
+ # Normal cycles are bounded so flush cadence cannot starve under sustained producers.
206
+ drained = 0
207
+ until @queue.empty?
208
+ break if max_events && drained >= max_events
209
+
210
+ begin
211
+ event = @queue.pop(true)
212
+ @aggregator.record(
213
+ flag_key: event[:flag_key].to_s,
214
+ variant: event[:variant],
215
+ allocation_key: event[:allocation_key],
216
+ targeting_key: event[:targeting_key],
217
+ eval_time_ms: event[:eval_time_ms].to_i,
218
+ attrs: event[:attrs].is_a?(Hash) ? event[:attrs] : {},
219
+ error_message: event[:error_message],
220
+ runtime_default: event[:runtime_default],
221
+ )
222
+ drained += 1
223
+ rescue ThreadError
224
+ break
225
+ end
226
+ end
227
+ drained
228
+ end
229
+
230
+ def flush_once
231
+ snapshot = @aggregator.flush_and_reset
232
+ dropped_overflow = snapshot[:dropped_degraded_overflow].to_i
233
+ dropped_queue = take_dropped_queue_overflow
234
+
235
+ emit_drop_counts(dropped_queue, dropped_overflow)
236
+
237
+ events = build_events(snapshot)
238
+ emit_degraded_counts(snapshot[:degraded].values.sum { |entry| entry[:count].to_i })
239
+ return if events.empty?
240
+
241
+ send_payload_batches(events)
242
+ rescue => e
243
+ @logger.debug { "OpenFeature EVP: flush error: #{e.class}: #{e.message}" }
244
+ end
245
+
246
+ # Read-and-reset the queue-overflow drop counter under the stop mutex.
247
+ def take_dropped_queue_overflow
248
+ @stop_mutex.synchronize do
249
+ count = @dropped_queue_overflow
250
+ @dropped_queue_overflow = 0
251
+ count
252
+ end
253
+ end
254
+
255
+ # Emit (log) the observable drop counts so backpressure is never silently lost.
256
+ # Σ(emitted tier counts + these drops) == evaluations processed.
257
+ def emit_drop_counts(dropped_queue, dropped_overflow)
258
+ return if dropped_queue.zero? && dropped_overflow.zero?
259
+
260
+ record_telemetry_count(ROWS_DROPPED_METRIC, dropped_queue, reason: REASON_QUEUE_OVERFLOW)
261
+ record_telemetry_count(ROWS_DROPPED_METRIC, dropped_overflow, reason: REASON_DEGRADED_CAP)
262
+
263
+ @logger.debug do
264
+ 'OpenFeature EVP: dropped events ' \
265
+ "queue_overflow=#{dropped_queue} degraded_overflow=#{dropped_overflow}"
266
+ end
267
+ end
268
+
269
+ def emit_degraded_counts(degraded_count)
270
+ record_telemetry_count(ROWS_DEGRADED_METRIC, degraded_count, reason: REASON_CARDINALITY_CAP)
271
+ end
272
+
273
+ # Build flagEvaluationEvent list from aggregation snapshot.
274
+ # Full-tier entries include all optional fields; degraded entries omit targeting_key + context.
275
+ def build_events(snapshot)
276
+ flush_time_ms = (Core::Utils::Time.now.to_f * 1000).to_i
277
+ events = []
278
+
279
+ snapshot[:full].each do |key, entry|
280
+ flag_key, variant, allocation_key, _runtime_default, _error_message, targeting_key, _ctx_key = key
281
+ event = build_event(
282
+ flag_key: flag_key, variant: variant, allocation_key: allocation_key,
283
+ targeting_key: targeting_key, entry: entry, flush_time_ms: flush_time_ms, tier: :full,
284
+ )
285
+ events << event
286
+ end
287
+
288
+ snapshot[:degraded].each do |key, entry|
289
+ flag_key, variant, allocation_key, _runtime_default, _error_message = key
290
+ event = build_event(
291
+ flag_key: flag_key, variant: variant, allocation_key: allocation_key,
292
+ targeting_key: nil, entry: entry, flush_time_ms: flush_time_ms, tier: :degraded,
293
+ )
294
+ events << event
295
+ end
296
+
297
+ events
298
+ end
299
+
300
+ def build_event(flag_key:, variant:, allocation_key:, targeting_key:, entry:, flush_time_ms:, tier:)
301
+ # @type var event: ::Hash[::String, untyped]
302
+ event = {
303
+ 'timestamp' => flush_time_ms,
304
+ 'flag' => {'key' => flag_key},
305
+ 'first_evaluation' => entry[:first_evaluation],
306
+ 'last_evaluation' => entry[:last_evaluation],
307
+ 'evaluation_count' => entry[:count],
308
+ }
309
+
310
+ event['runtime_default_used'] = true if entry[:runtime_default]
311
+ event['error'] = {'message' => entry[:error_message]} if entry[:error_message] && !entry[:error_message].empty?
312
+
313
+ # variant + allocation are present in both tiers (omitempty per schema).
314
+ event['variant'] = {'key' => variant} if variant && !variant.empty?
315
+ event['allocation'] = {'key' => allocation_key} if allocation_key && !allocation_key.empty?
316
+
317
+ # Full-tier additionally carries targeting_key + the pruned evaluation context;
318
+ # the degraded tier omits both.
319
+ if tier == :full
320
+ event['targeting_key'] = targeting_key if targeting_key && !targeting_key.empty?
321
+
322
+ if entry[:context_attrs] && !entry[:context_attrs].empty?
323
+ event['context'] = {'evaluation' => entry[:context_attrs]}
324
+ end
325
+ end
326
+
327
+ event
328
+ end
329
+
330
+ def send_payload_batches(events)
331
+ context_json = Core::Encoding::JSONEncoder.encode(@service_context)
332
+ payload_prefix = "{\"context\":#{context_json},\"flagEvaluations\":["
333
+ payload_suffix = ']}'
334
+ base_payload_size = payload_prefix.bytesize + payload_suffix.bytesize
335
+
336
+ batch = []
337
+ batch_size = base_payload_size
338
+ dropped_oversized = 0
339
+ payload_limit_degraded = 0
340
+ payload_splits = 0
341
+
342
+ events.each do |event|
343
+ encoded_event = encoded_event_for_payload(event, base_payload_size)
344
+ unless encoded_event
345
+ dropped_oversized += event_count(event)
346
+ next
347
+ end
348
+
349
+ event_hash, event_size, degraded_for_payload_limit = encoded_event
350
+ payload_limit_degraded += event_count(event_hash) if degraded_for_payload_limit
351
+ separator_size = batch.empty? ? 0 : 1
352
+
353
+ if !batch.empty? && batch_size + separator_size + event_size > self.class::PAYLOAD_SIZE_LIMIT_BYTES
354
+ send_payload_batch(batch)
355
+ batch = []
356
+ batch_size = base_payload_size
357
+ separator_size = 0
358
+ payload_splits += 1
359
+ end
360
+
361
+ batch << event_hash
362
+ batch_size += separator_size + event_size
363
+ end
364
+
365
+ send_payload_batch(batch) unless batch.empty?
366
+ record_telemetry_count(ROWS_DEGRADED_METRIC, payload_limit_degraded, reason: REASON_PAYLOAD_LIMIT)
367
+ record_telemetry_count(ROWS_DROPPED_METRIC, dropped_oversized, reason: REASON_PAYLOAD_LIMIT)
368
+ record_telemetry_count(PAYLOAD_SPLITS_METRIC, payload_splits)
369
+ emit_payload_oversize_drops(dropped_oversized) if dropped_oversized.positive?
370
+ end
371
+
372
+ def send_payload_batch(events)
373
+ response = @transport.send_flag_evaluations(
374
+ {
375
+ 'context' => @service_context,
376
+ 'flagEvaluations' => events,
377
+ }
378
+ )
379
+ if response.respond_to?(:ok?) && !response.ok?
380
+ @logger.debug { "OpenFeature EVP: transport response was not OK: #{response.inspect}" }
381
+ end
382
+ response
383
+ end
384
+
385
+ def encoded_event_for_payload(event, base_payload_size)
386
+ event_hash, event_size = encoded_event(event)
387
+ return [event_hash, event_size, false] if event_fits_payload?(event_size, base_payload_size)
388
+
389
+ degraded = degrade_event_for_payload_limit(event)
390
+ return unless degraded
391
+
392
+ degraded_hash, degraded_size = encoded_event(degraded)
393
+ [degraded_hash, degraded_size, true] if event_fits_payload?(degraded_size, base_payload_size)
394
+ end
395
+
396
+ def encoded_event(event)
397
+ [event, Core::Encoding::JSONEncoder.encode(event).bytesize]
398
+ end
399
+
400
+ def event_fits_payload?(event_size, base_payload_size)
401
+ base_payload_size + event_size <= self.class::PAYLOAD_SIZE_LIMIT_BYTES
402
+ end
403
+
404
+ def degrade_event_for_payload_limit(event)
405
+ return unless event.key?('targeting_key') || event.key?('context')
406
+
407
+ degraded = event.dup
408
+ degraded.delete('targeting_key')
409
+ degraded.delete('context')
410
+ degraded
411
+ end
412
+
413
+ def emit_payload_oversize_drops(dropped_oversized)
414
+ @logger.debug { "OpenFeature EVP: dropped events payload_oversize=#{dropped_oversized}" }
415
+ end
416
+
417
+ def event_count(event)
418
+ count = event['evaluation_count'].to_i
419
+ count.positive? ? count : 1
420
+ end
421
+
422
+ def record_telemetry_count(metric_name, count, reason: nil)
423
+ return unless count.positive?
424
+
425
+ tags = reason ? {reason: reason} : {}
426
+ @telemetry&.inc(TELEMETRY_NAMESPACE, metric_name, count, tags: tags)
427
+ rescue => e
428
+ @logger.debug { "OpenFeature EVP: telemetry error: #{e.class}: #{e.message}" }
429
+ end
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module OpenFeature
5
+ module Hooks
6
+ # EVP flagevaluation hook — structural copy of FlagEvalMetricsHook for the new EVP path.
7
+ #
8
+ # This hook does ONLY cheap capture + non-blocking enqueue on the caller's eval thread.
9
+ # All aggregation (canonical key, tier routing, cap enforcement) is performed
10
+ # in the background by the FlagEvaluation::Writer.
11
+ #
12
+ # OTel non-regression: hooks/flag_eval_metrics_hook.rb and metrics/flag_eval_metrics.rb
13
+ # stay on the OTel path. This hook is registered as a provider hook so it receives
14
+ # the SDK-final EvaluationDetails after hook failures and type validation.
15
+ class FlagEvalEVPHook
16
+ TYPE_MISMATCH_ERROR_CODE = 'TYPE_MISMATCH'
17
+
18
+ # Include the Hook module if available (SDK >= 0.5.0) for interface documentation
19
+ # and default implementations of other hook methods (before, after, error)
20
+ include ::OpenFeature::SDK::Hooks::Hook if defined?(::OpenFeature::SDK::Hooks::Hook)
21
+
22
+ # Returns true if the OpenFeature SDK supports hooks (>= 0.5.0)
23
+ def self.available?
24
+ !!defined?(::OpenFeature::SDK::Hooks::Hook)
25
+ end
26
+
27
+ def initialize(writer)
28
+ @writer = writer
29
+ end
30
+
31
+ # finally covers success, error, AND default paths (not just After).
32
+ # Cheap extraction only — no aggregation on the caller thread.
33
+ def finally(hook_context:, evaluation_details:, **_opts)
34
+ writer = @writer
35
+ return unless writer
36
+
37
+ # Eval-time stamped by the provider at eval-entry time; fall back to hook-fire time.
38
+ # Metadata key: 'dd.eval.timestamp_ms' (int, ms since epoch) — stamped at eval entry.
39
+ metadata = evaluation_details.flag_metadata
40
+ eval_time_ms = metadata.is_a?(Hash) ? metadata['dd.eval.timestamp_ms'] : nil
41
+ eval_time_ms ||= (Core::Utils::Time.now.to_f * 1000).to_i
42
+
43
+ writer.enqueue(
44
+ flag_key: hook_context.flag_key,
45
+ variant: evaluation_details.variant,
46
+ allocation_key: extract_allocation_key(evaluation_details),
47
+ error_message: extract_error_message(evaluation_details),
48
+ runtime_default: runtime_default?(evaluation_details),
49
+ targeting_key: extract_targeting_key(hook_context.evaluation_context),
50
+ eval_time_ms: eval_time_ms,
51
+ attrs: extract_attributes(hook_context.evaluation_context),
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def extract_targeting_key(evaluation_context)
58
+ return unless evaluation_context&.respond_to?(:targeting_key)
59
+
60
+ evaluation_context.targeting_key
61
+ end
62
+
63
+ def extract_attributes(evaluation_context)
64
+ return {} unless evaluation_context
65
+
66
+ if evaluation_context.respond_to?(:attributes)
67
+ return evaluation_context.attributes || {}
68
+ end
69
+
70
+ return {} unless evaluation_context.respond_to?(:fields)
71
+
72
+ fields = evaluation_context.fields
73
+ return {} unless fields.is_a?(Hash)
74
+
75
+ fields.reject { |k, _| k.to_s == ::OpenFeature::SDK::EvaluationContext::TARGETING_KEY }
76
+ end
77
+
78
+ # Same key as OTel hook ('__dd_allocation_key') — do not diverge.
79
+ def extract_allocation_key(evaluation_details)
80
+ metadata = evaluation_details.flag_metadata
81
+ return unless metadata.is_a?(Hash)
82
+
83
+ metadata['__dd_allocation_key']
84
+ end
85
+
86
+ def extract_error_message(evaluation_details)
87
+ return unless evaluation_details.respond_to?(:error_message)
88
+
89
+ evaluation_details.error_message
90
+ end
91
+
92
+ def runtime_default?(evaluation_details)
93
+ return true if evaluation_details.variant.nil?
94
+ return false unless evaluation_details.respond_to?(:error_code)
95
+
96
+ evaluation_details.error_code.to_s == TYPE_MISMATCH_ERROR_CODE
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -8,7 +8,7 @@ module Datadog
8
8
  # Compatible with OpenFeature SDK >= 0.5.0 which provides the Hooks::Hook module,
9
9
  # but also works with older versions since the SDK uses respond_to?(:finally)
10
10
  # to detect hook capabilities.
11
- class FlagEvalHook
11
+ class FlagEvalMetricsHook
12
12
  # Include the Hook module if available (SDK >= 0.5.0) for interface documentation
13
13
  # and default implementations of other hook methods (before, after, error)
14
14
  include ::OpenFeature::SDK::Hooks::Hook if defined?(::OpenFeature::SDK::Hooks::Hook)
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../core/feature_flags'
4
+ require_relative 'ext'
5
+ require_relative 'resolution_details'
4
6
 
5
7
  module Datadog
6
8
  module OpenFeature
7
9
  # This class is an interface of evaluation logic using native extension
8
10
  class NativeEvaluator
11
+ INVALID_FLAG_CONFIGURATION_ERROR_MESSAGE = 'flag configuration is invalid or unsupported'
12
+
9
13
  # NOTE: In a currect implementation configuration is expected to be a raw
10
14
  # JSON string containing feature flags (straight from the remote config)
11
15
  # in the format expected by `libdatadog` without any modifications
@@ -27,12 +31,30 @@ module Datadog
27
31
  def get_assignment(flag_key, default_value:, expected_type:, context:)
28
32
  result = @configuration.get_assignment(flag_key, expected_type, context)
29
33
 
34
+ return invalid_flag_configuration_error(default_value) if invalid_flag_configuration?(result)
35
+
30
36
  # NOTE: This is a special case when we need to fallback to the default
31
37
  # value, even tho the evaluation itself doesn't produce an error
32
38
  # resolution details
33
39
  result.value = default_value if result.variant.nil?
34
40
  result
35
41
  end
42
+
43
+ private
44
+
45
+ def invalid_flag_configuration?(result)
46
+ result.reason == Ext::DEFAULT &&
47
+ result.error_code.nil? &&
48
+ result.error_message == INVALID_FLAG_CONFIGURATION_ERROR_MESSAGE
49
+ end
50
+
51
+ def invalid_flag_configuration_error(default_value)
52
+ ResolutionDetails.build_error(
53
+ value: default_value,
54
+ error_code: Ext::PARSE_ERROR,
55
+ error_message: INVALID_FLAG_CONFIGURATION_ERROR_MESSAGE
56
+ )
57
+ end
36
58
  end
37
59
  end
38
60
  end