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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/ext/datadog_profiling_native_extension/clock_id_from_mach.c +5 -12
- data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +6 -3
- data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +17 -3
- data/ext/datadog_profiling_native_extension/collectors_thread_context.c +219 -98
- data/ext/datadog_profiling_native_extension/collectors_thread_context.h +2 -0
- data/ext/datadog_profiling_native_extension/extconf.rb +11 -0
- data/ext/datadog_profiling_native_extension/ruby_helpers.c +7 -0
- data/ext/libdatadog_api/di.c +76 -0
- data/ext/libdatadog_api/extconf.rb +6 -0
- data/ext/libdatadog_extconf_helpers.rb +1 -1
- data/lib/datadog/ai_guard/api_client.rb +6 -4
- data/lib/datadog/appsec/api_security/route_extractor.rb +7 -4
- data/lib/datadog/appsec/configuration.rb +6 -0
- data/lib/datadog/appsec/context.rb +4 -2
- data/lib/datadog/appsec/contrib/excon/ssrf_detection_middleware.rb +59 -4
- data/lib/datadog/appsec/contrib/faraday/ssrf_detection_middleware.rb +59 -5
- data/lib/datadog/appsec/contrib/graphql/gateway/multiplex.rb +1 -1
- data/lib/datadog/appsec/contrib/rack/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/contrib/rack/input_peeker.rb +4 -17
- data/lib/datadog/appsec/contrib/rack/request_middleware.rb +21 -0
- data/lib/datadog/appsec/contrib/rails/gateway/request.rb +2 -1
- data/lib/datadog/appsec/contrib/rest_client/request_ssrf_detection_patch.rb +60 -7
- data/lib/datadog/appsec/contrib/sinatra/gateway/watcher.rb +4 -0
- data/lib/datadog/appsec/ext.rb +1 -0
- data/lib/datadog/appsec/metrics/collector.rb +18 -1
- data/lib/datadog/appsec/metrics/exporter.rb +9 -3
- data/lib/datadog/appsec/remote.rb +9 -3
- data/lib/datadog/appsec/route_normalizer/rails_route_pattern.rb +176 -0
- data/lib/datadog/appsec/route_normalizer/route_pattern.rb +378 -0
- data/lib/datadog/appsec/route_normalizer/route_text.rb +57 -0
- data/lib/datadog/appsec/route_normalizer.rb +80 -0
- data/lib/datadog/appsec/utils/http/body_reader.rb +61 -0
- data/lib/datadog/core/configuration/components.rb +82 -2
- data/lib/datadog/core/configuration/components_state.rb +6 -1
- data/lib/datadog/core/configuration/settings.rb +0 -3
- data/lib/datadog/core/configuration/supported_configurations.rb +2 -0
- data/lib/datadog/core/evp.rb +11 -0
- data/lib/datadog/core/remote/client/capabilities.rb +34 -7
- data/lib/datadog/core/telemetry/event/app_started.rb +8 -1
- data/lib/datadog/di/base.rb +3 -1
- data/lib/datadog/di/code_tracker.rb +5 -2
- data/lib/datadog/di/component.rb +110 -36
- data/lib/datadog/di/error.rb +10 -0
- data/lib/datadog/di/fatal_exceptions.rb +26 -0
- data/lib/datadog/di/instrumenter.rb +381 -165
- data/lib/datadog/di/probe.rb +14 -0
- data/lib/datadog/di/probe_file_loader.rb +8 -3
- data/lib/datadog/di/probe_manager.rb +67 -9
- data/lib/datadog/di/probe_notification_builder.rb +4 -1
- data/lib/datadog/di/probe_notifier_worker.rb +52 -32
- data/lib/datadog/di/redactor.rb +16 -1
- data/lib/datadog/di/remote.rb +175 -14
- data/lib/datadog/di/serializer.rb +12 -5
- data/lib/datadog/di/transport/input.rb +8 -4
- data/lib/datadog/di.rb +63 -0
- data/lib/datadog/kit/appsec/events/v2.rb +60 -2
- data/lib/datadog/open_feature/component.rb +29 -6
- data/lib/datadog/open_feature/configuration.rb +8 -0
- data/lib/datadog/open_feature/ext.rb +2 -0
- data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +286 -0
- data/lib/datadog/open_feature/flag_evaluation/writer.rb +433 -0
- data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +101 -0
- data/lib/datadog/open_feature/hooks/{flag_eval_hook.rb → flag_eval_metrics_hook.rb} +1 -1
- data/lib/datadog/open_feature/native_evaluator.rb +22 -0
- data/lib/datadog/open_feature/provider.rb +59 -27
- data/lib/datadog/open_feature/transport.rb +55 -1
- data/lib/datadog/opentelemetry/sdk/logs_exporter.rb +5 -12
- data/lib/datadog/opentelemetry/sdk/metrics_exporter.rb +4 -11
- data/lib/datadog/opentelemetry/sdk.rb +19 -0
- data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +1 -1
- data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -3
- data/lib/datadog/symbol_database/component.rb +164 -47
- data/lib/datadog/symbol_database/configuration.rb +12 -4
- data/lib/datadog/symbol_database/extensions.rb +19 -0
- data/lib/datadog/symbol_database/extractor.rb +124 -87
- data/lib/datadog/symbol_database/file_hash.rb +3 -1
- data/lib/datadog/symbol_database/remote.rb +8 -3
- data/lib/datadog/symbol_database/scope_batcher.rb +7 -3
- data/lib/datadog/symbol_database/uploader.rb +9 -2
- data/lib/datadog/symbol_database.rb +22 -0
- data/lib/datadog/tracing/distributed/baggage.rb +2 -1
- data/lib/datadog/tracing/ext.rb +9 -0
- data/lib/datadog/tracing/remote.rb +34 -3
- data/lib/datadog/version.rb +1 -1
- data/lib/datadog.rb +1 -0
- 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
|
|
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
|