datadog 2.41.0 → 2.42.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -2
  3. data/ext/datadog_profiling_native_extension/NativeExtensionDesign.md +12 -24
  4. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +110 -61
  5. data/ext/datadog_profiling_native_extension/collectors_stack.c +11 -3
  6. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +141 -103
  7. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +7 -3
  8. data/ext/datadog_profiling_native_extension/datadog_ruby_common.h +0 -10
  9. data/ext/datadog_profiling_native_extension/extconf.rb +52 -93
  10. data/ext/datadog_profiling_native_extension/heap_recorder.c +338 -255
  11. data/ext/datadog_profiling_native_extension/heap_recorder.h +46 -31
  12. data/ext/datadog_profiling_native_extension/native_extension_helpers.rb +0 -24
  13. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +509 -440
  14. data/ext/datadog_profiling_native_extension/private_vm_api_access.h +15 -3
  15. data/ext/datadog_profiling_native_extension/profiling.c +2 -0
  16. data/ext/datadog_profiling_native_extension/ruby_helpers.c +1 -79
  17. data/ext/datadog_profiling_native_extension/ruby_helpers.h +0 -7
  18. data/ext/datadog_profiling_native_extension/stack_recorder.c +93 -61
  19. data/ext/datadog_profiling_native_extension/stack_recorder.h +12 -4
  20. data/ext/libdatadog_api/datadog_ruby_common.h +0 -10
  21. data/ext/libdatadog_api/di.c +10 -0
  22. data/ext/libdatadog_api/extconf.rb +3 -0
  23. data/ext/libdatadog_api/init.c +2 -0
  24. data/ext/libdatadog_api/otel_thread_context.c +232 -0
  25. data/ext/libdatadog_api/otel_thread_context.h +5 -0
  26. data/ext/libdatadog_extconf_helpers.rb +1 -1
  27. data/lib/datadog/appsec/assets/blocked.html +1 -108
  28. data/lib/datadog/core/configuration/components.rb +1 -0
  29. data/lib/datadog/core/crashtracking/component.rb +5 -1
  30. data/lib/datadog/data_streams/pathway_context.rb +20 -22
  31. data/lib/datadog/data_streams/processor.rb +31 -0
  32. data/lib/datadog/di/instrumenter.rb +41 -1
  33. data/lib/datadog/di/logger.rb +2 -2
  34. data/lib/datadog/di/probe.rb +9 -1
  35. data/lib/datadog/di/probe_notification_builder.rb +1 -0
  36. data/lib/datadog/di/remote.rb +3 -3
  37. data/lib/datadog/open_feature/evaluation_engine.rb +29 -3
  38. data/lib/datadog/open_feature/exposures/event.rb +10 -3
  39. data/lib/datadog/open_feature/ext.rb +19 -0
  40. data/lib/datadog/open_feature/flag_evaluation/aggregator.rb +236 -80
  41. data/lib/datadog/open_feature/flag_evaluation/writer.rb +179 -68
  42. data/lib/datadog/open_feature/hooks/flag_eval_evp_hook.rb +24 -21
  43. data/lib/datadog/open_feature/native_evaluator.rb +33 -6
  44. data/lib/datadog/open_feature/noop_evaluator.rb +5 -0
  45. data/lib/datadog/open_feature/provider.rb +11 -2
  46. data/lib/datadog/opentelemetry/sdk/propagator.rb +1 -1
  47. data/lib/datadog/opentelemetry/trace.rb +3 -0
  48. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +3 -0
  49. data/lib/datadog/profiling/collectors/thread_context.rb +0 -4
  50. data/lib/datadog/profiling/component.rb +8 -16
  51. data/lib/datadog/tracing/contrib/active_record/events/sql.rb +1 -0
  52. data/lib/datadog/tracing/distributed/baggage.rb +0 -1
  53. data/lib/datadog/tracing/distributed/datadog.rb +3 -3
  54. data/lib/datadog/tracing/distributed/propagation.rb +3 -0
  55. data/lib/datadog/tracing/distributed/trace_context.rb +14 -271
  56. data/lib/datadog/tracing/distributed/trace_state/datadog.rb +233 -0
  57. data/lib/datadog/tracing/distributed/trace_state/ext.rb +44 -0
  58. data/lib/datadog/tracing/distributed/trace_state/open_telemetry.rb +156 -0
  59. data/lib/datadog/tracing/distributed/trace_state.rb +121 -0
  60. data/lib/datadog/tracing/otel_thread_context.rb +30 -0
  61. data/lib/datadog/tracing/remote.rb +195 -27
  62. data/lib/datadog/tracing/sampling/rule_sampler.rb +2 -0
  63. data/lib/datadog/tracing/trace_digest.rb +22 -4
  64. data/lib/datadog/tracing/trace_operation.rb +22 -10
  65. data/lib/datadog/tracing/tracer.rb +5 -5
  66. data/lib/datadog/version.rb +1 -1
  67. metadata +14 -8
  68. data/lib/datadog/tracing/distributed/datadog_tags_codec.rb +0 -69
@@ -23,27 +23,42 @@ module Datadog
23
23
  end
24
24
 
25
25
  def fetch_value(flag_key, default_value:, expected_type:, evaluation_context: nil)
26
+ # Pre-bound to the safe default so the rescue below can still read it if the
27
+ # evaluator read itself raises.
28
+ observe_full_evaluation_data = false
29
+ # Snapshot the evaluator once: a concurrent reconfigure! must not retroactively
30
+ # apply a different environment's full-evaluation-data policy to this evaluation.
31
+ evaluator = @evaluator
32
+ observe_full_evaluation_data = evaluator.observe_full_evaluation_data == true
33
+
26
34
  unless ALLOWED_TYPES.include?(expected_type)
27
35
  message = "unknown type #{expected_type.inspect}, allowed types #{ALLOWED_TYPES.join(", ")}"
28
- return ResolutionDetails.build_error(
36
+ result = ResolutionDetails.build_error(
29
37
  value: default_value, error_code: Ext::UNKNOWN_TYPE, error_message: message
30
38
  )
39
+
40
+ return with_observe_full_evaluation_data(result, observe_full_evaluation_data)
31
41
  end
32
42
 
33
43
  context = evaluation_context&.fields.to_h
34
- result = @evaluator.get_assignment(
44
+ result = evaluator.get_assignment(
35
45
  flag_key, default_value: default_value, context: context, expected_type: expected_type
36
46
  )
37
47
 
48
+ result = with_observe_full_evaluation_data(result, observe_full_evaluation_data)
49
+
38
50
  @reporter.report(result, flag_key: flag_key, context: evaluation_context)
39
51
 
40
52
  result
41
53
  rescue => e
42
54
  @telemetry.report(e, description: "OpenFeature: Failed to fetch flag value")
43
55
 
44
- ResolutionDetails.build_error(
56
+ result = ResolutionDetails.build_error(
45
57
  value: default_value, error_code: Ext::GENERAL, error_message: "#{e.class}: #{e.message}"
46
58
  )
59
+ # `== true` is load-bearing: Steep types this local as `(nil | bool)` in a rescue
60
+ # body, and coercing at the use site is what narrows it to `bool`.
61
+ with_observe_full_evaluation_data(result, observe_full_evaluation_data == true)
47
62
  end
48
63
 
49
64
  # NOTE: In a currect implementation configuration is expected to be a raw
@@ -65,6 +80,17 @@ module Datadog
65
80
 
66
81
  raise ReconfigurationError, "#{e.class}: #{e.message}"
67
82
  end
83
+
84
+ private
85
+
86
+ # Stamps observe_full_evaluation_data onto the result metadata so the hook
87
+ # reads it from the event, not live config.
88
+ def with_observe_full_evaluation_data(result, observe_full_evaluation_data)
89
+ result = result.dup if result.frozen?
90
+ metadata = result.flag_metadata ||= {}
91
+ metadata[Ext::METADATA_OBSERVE_FULL_EVALUATION_DATA] = observe_full_evaluation_data
92
+ result
93
+ end
68
94
  end
69
95
  end
70
96
  end
@@ -15,12 +15,14 @@ module Datadog
15
15
  "#{flag_key}:#{context.targeting_key}"
16
16
  end
17
17
 
18
+ # NOTE: The serial id is part of the value because it can appear or change on a
19
+ # configuration refresh while the allocation and the variant stay the same.
18
20
  def cache_value(result, flag_key:, context:)
19
- "#{result.allocation_key}:#{result.variant}"
21
+ "#{result.allocation_key}:#{result.variant}:#{result.serial_id}"
20
22
  end
21
23
 
22
24
  def build(result, flag_key:, context:)
23
- {
25
+ event = {
24
26
  timestamp: current_timestamp_ms,
25
27
  allocation: {
26
28
  key: result.allocation_key,
@@ -35,7 +37,12 @@ module Datadog
35
37
  id: context.targeting_key,
36
38
  attributes: extract_attributes(context),
37
39
  },
38
- }.freeze
40
+ }
41
+
42
+ serial_id = result.serial_id
43
+ event[:serial_id] = serial_id unless serial_id.nil?
44
+
45
+ event.freeze
39
46
  end
40
47
 
41
48
  private
@@ -11,11 +11,30 @@ module Datadog
11
11
  GENERAL = "GENERAL"
12
12
  PROVIDER_FATAL = "PROVIDER_FATAL"
13
13
  PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
14
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
15
+ TYPE_MISMATCH = "TYPE_MISMATCH"
16
+ TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
17
+ INVALID_CONTEXT = "INVALID_CONTEXT"
18
+
19
+ STANDARD_ERROR_CODES = [
20
+ PROVIDER_NOT_READY,
21
+ FLAG_NOT_FOUND,
22
+ PARSE_ERROR,
23
+ TYPE_MISMATCH,
24
+ TARGETING_KEY_MISSING,
25
+ INVALID_CONTEXT,
26
+ PROVIDER_FATAL,
27
+ GENERAL,
28
+ ].freeze
14
29
 
15
30
  # Flag-metadata key under which the provider threads the assignment's
16
31
  # allocation key to the flag-evaluation hooks. The wire string is the
17
32
  # value so the writer (provider) and readers (EVP/metrics hooks) can't drift.
18
33
  METADATA_ALLOCATION_KEY = "__dd_allocation_key"
34
+
35
+ # Stamped from the UFC the evaluation ran against; the hook reads only this
36
+ # key, never live config.
37
+ METADATA_OBSERVE_FULL_EVALUATION_DATA = "__dd_observe_full_evaluation_data"
19
38
  end
20
39
  end
21
40
  end
@@ -1,21 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Datadog
4
6
  module OpenFeature
5
7
  module FlagEvaluation
6
8
  # Two-tier aggregation for EVP flagevaluation events.
7
9
  #
8
10
  # Two-tier design:
9
- # - full-tier key: (flag_key, variant, allocation_key, runtime_default, error_message, targeting_key, canonical_context_key)
11
+ # - full-tier key: (flag_key, variant, allocation_key, runtime_default, error_message,
12
+ # targeting_key, canonical_context_key, observe_full_evaluation_data)
10
13
  # - degraded-tier key: (flag_key, variant, allocation_key, runtime_default, error_message)
11
14
  # - Drop-and-count when degraded tier is full
12
15
  # - canonical_context_key: sorted type-tagged length-delimited encoding (no hash digest)
13
16
  # - Caps: global_cap=131_072 / per_flag_cap=10_000 / degraded_cap=32_768
14
- # - Context pruning: 256 fields / 256 chars (matches flageval-worker backend limits)
17
+ #
18
+ # Context bounding: the writer applies `bounded_context_snapshot` on the evaluation
19
+ # thread before enqueue, so the queue only ever holds an already-bounded, flattened
20
+ # snapshot. `record` receives that snapshot and keys on it directly.
15
21
  class Aggregator
22
+ # Cross-SDK context caps. The per-dimension caps match Go and Java.
16
23
  MAX_CONTEXT_FIELDS = 256
17
- MAX_FIELD_LENGTH = 256
18
- MAX_CONTEXT_DEPTH = 32
24
+ MAX_VALUE_LENGTH = 256
25
+ MAX_KEY_LENGTH = 256
26
+ MAX_LIST_ELEMENTS = 256
27
+ MAX_STRUCTURE_PROPERTIES = 256
28
+ MAX_SNAPSHOT_DEPTH = 4
29
+
30
+ # Total-node budget bounding leaf-free shared subtrees, which do not increase the
31
+ # retained field count. It still permits every retained field to sit at the
32
+ # maximum snapshot depth.
33
+ MAX_VISITED_NODES = MAX_CONTEXT_FIELDS * (MAX_SNAPSHOT_DEPTH + 1)
34
+
35
+ # Truncation reason labels, surfaced on the `flagevaluation.context.truncated`
36
+ # telemetry counter so operators can tell which cap was hit.
37
+ REASON_MAX_CONTEXT_FIELDS = "max_context_fields"
38
+ REASON_MAX_VALUE_LENGTH = "max_value_length"
39
+ REASON_MAX_KEY_LENGTH = "max_key_length"
40
+ REASON_MAX_LIST_ELEMENTS = "max_list_elements"
41
+ REASON_MAX_STRUCTURE_PROPERTIES = "max_structure_properties"
42
+ REASON_MAX_SNAPSHOT_DEPTH = "max_snapshot_depth"
43
+ REASON_MAX_VISITED_NODES = "max_visited_nodes"
44
+ REASON_CYCLE = "cycle"
19
45
 
20
46
  # Type tags so values of different Ruby types never collide in the canonical key.
21
47
  CTX_TAG_STRING = "s"
@@ -62,35 +88,44 @@ module Datadog
62
88
  # Record one evaluation event. Thread-safe. Called from the background writer.
63
89
  def record(
64
90
  flag_key:, variant:, allocation_key:, targeting_key:, eval_time_ms:, attrs:, error_message: nil,
65
- runtime_default: nil
91
+ runtime_default: nil, observe_full_evaluation_data: false
66
92
  )
67
93
  runtime_default = variant.nil? if runtime_default.nil?
68
94
  runtime_default = !!runtime_default
95
+ # Runtime defaults have no resolved variant or allocation in the EVP schema.
96
+ if runtime_default
97
+ variant = nil
98
+ allocation_key = nil
99
+ end
100
+ observe_full_evaluation_data = !!observe_full_evaluation_data
69
101
 
70
- # Normalize nil/empty strings
102
+ # Preserve targeting-key presence; other dimensions use empty strings.
71
103
  variant = variant.to_s
72
104
  allocation_key = allocation_key.to_s
73
105
  error_message = error_message.to_s
74
- targeting_key = targeting_key.to_s
75
-
76
- # Context pruning + canonical key (see prune_context and canonical_context_key).
77
- # Runs in the background writer so caller eval threads do not pay the flatten/prune cost.
78
- pruned_context = prune_context(attrs)
79
- context_key = canonical_context_key(pruned_context)
80
-
81
- full_key = [flag_key, variant, allocation_key, runtime_default, error_message, targeting_key, context_key]
106
+ targeting_key = targeting_key.to_s unless targeting_key.nil?
107
+
108
+ context_key = observe_full_evaluation_data ? canonical_context_key(attrs) : nil
109
+ # @type var full_key: full_key
110
+ full_key = [
111
+ flag_key, variant, allocation_key, runtime_default, error_message,
112
+ targeting_key, context_key, observe_full_evaluation_data,
113
+ ]
82
114
  evaluation_time_ms = eval_time_ms.to_i
83
115
 
84
116
  @mutex.synchronize do
85
117
  # --- Full tier ---
86
118
  if (entry = @full[full_key])
87
- observe(entry, evaluation_time_ms)
119
+ observe(entry, evaluation_time_ms, observe_full_evaluation_data)
88
120
  return
89
121
  end
90
122
 
91
123
  per_flag_count = @per_flag_full[flag_key]
92
124
  if per_flag_count >= @per_flag_cap
93
- add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
125
+ add_to_degraded(
126
+ flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms,
127
+ observe_full_evaluation_data
128
+ )
94
129
  return
95
130
  end
96
131
 
@@ -104,21 +139,24 @@ module Datadog
104
139
  runtime_default: runtime_default,
105
140
  error_message: error_message,
106
141
  targeting_key: targeting_key,
107
- context_attrs: pruned_context
142
+ context_attrs: observe_full_evaluation_data ? attrs : nil,
143
+ observe_full_evaluation_data: observe_full_evaluation_data
108
144
  )
109
145
  @full[full_key] = entry
110
146
  @global_count += 1
111
147
  else
112
148
  # Route to degraded tier
113
- add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
149
+ add_to_degraded(
150
+ flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms,
151
+ observe_full_evaluation_data
152
+ )
114
153
  end
115
154
  end
116
155
  end
117
156
 
118
157
  # Flush aggregation maps, reset state, return snapshot.
119
- # Returns { full: Hash, degraded: Hash, dropped_degraded_overflow: Integer }.
120
- # The overflow count is included in the snapshot so the caller can EMIT it before it is
121
- # reset — the count is never reset-without-emit (backpressure stays observable).
158
+ # The overflow count is included in the snapshot so the caller can emit it before
159
+ # it is reset (never reset-without-emit).
122
160
  def flush_and_reset
123
161
  @mutex.synchronize do
124
162
  full_snapshot = @full
@@ -135,85 +173,192 @@ module Datadog
135
173
  end
136
174
  end
137
175
 
138
- # Prune context: keep first MAX_CONTEXT_FIELDS fields (sorted), skip string values >256 chars.
139
- # Keys are sorted before pruning to ensure deterministic subset selection.
140
- def prune_context(attrs)
141
- self.class.prune_context(attrs)
142
- end
143
-
144
- def self.prune_context(attrs)
145
- flattened_context = flatten_context(attrs)
146
- return {} if flattened_context.empty?
147
-
148
- pruned_context = {}
149
- count = 0
150
- flattened_context.keys.sort.each do |key|
151
- break if count >= MAX_CONTEXT_FIELDS
152
-
153
- value = flattened_context[key]
154
- # Skip oversized string values (mirrors flageval-worker pruning behavior).
155
- next if value.is_a?(String) && value.length > MAX_FIELD_LENGTH
156
-
157
- pruned_context[key] = value
158
- count += 1
159
- end
160
- pruned_context
161
- end
162
-
163
- def self.flatten_context(attrs)
164
- return {} unless attrs.is_a?(Hash) && !attrs.empty?
165
-
166
- flattened_context = {}
176
+ # Bound and flatten the caller's evaluation context on the evaluation thread,
177
+ # before enqueue, so the async queue only ever holds an already-bounded snapshot.
178
+ # Returns the flattened (dot-notation) context and the truncation reasons hit,
179
+ # which the writer surfaces on the `flagevaluation.context.truncated` counter.
180
+ #
181
+ # Ruby Hash insertion order lets traversal stop at the caps without sorting
182
+ # or scanning the complete input. Ruby therefore truncates where Go omits,
183
+ # whose map iteration is randomized per call.
184
+ def self.bounded_context_snapshot(attrs, excluded_key: nil)
185
+ return [{}, []] unless attrs.is_a?(Hash) && !attrs.empty?
186
+
187
+ flattened = {}
188
+ reasons = Set.new
167
189
  seen = {attrs.object_id => true}
190
+ walked = 0
191
+ # Single-element array so the recursion shares one mutable counter.
192
+ budget = [MAX_VISITED_NODES]
168
193
  attrs.each do |key, value|
169
- flatten_value(key.to_s, value, flattened_context, seen, 0)
194
+ key = context_key_string(key)
195
+ next unless key
196
+ next if key == excluded_key
197
+
198
+ if flattened.size >= MAX_CONTEXT_FIELDS
199
+ reasons << REASON_MAX_CONTEXT_FIELDS
200
+ reasons << REASON_MAX_STRUCTURE_PROPERTIES if walked >= MAX_STRUCTURE_PROPERTIES
201
+ break
202
+ end
203
+ if walked >= MAX_STRUCTURE_PROPERTIES
204
+ reasons << REASON_MAX_STRUCTURE_PROPERTIES
205
+ break
206
+ end
207
+ if budget[0] <= 0
208
+ reasons << REASON_MAX_VISITED_NODES
209
+ break
210
+ end
211
+ walked += 1
212
+ if key.length > MAX_KEY_LENGTH
213
+ reasons << REASON_MAX_KEY_LENGTH
214
+ next
215
+ end
216
+
217
+ key = key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
218
+ bounded_flatten(key, value, flattened, seen, 0, reasons, budget)
170
219
  end
171
- flattened_context
220
+ [flattened, reasons.to_a]
172
221
  end
173
222
 
174
- # Canonical context key: sorted type-tagged length-delimited encoding.
175
- # Each field is: 8-byte big-endian key length + key bytes + type-tag byte +
176
- # 8-byte big-endian value length + value bytes.
177
- # No hash digest — the key IS the full encoding (collision-free, no FNV).
178
- def canonical_context_key(attrs)
179
- return "" if attrs.nil? || attrs.empty?
180
-
181
- buffer = String.new("", encoding: Encoding::BINARY)
182
- attrs.keys.sort.each do |key|
183
- value = attrs[key]
184
- buffer << length_delimited(key.to_s)
185
- buffer << context_value_bytes(value)
223
+ def self.context_key_string(key)
224
+ case key
225
+ when String then String.new(key)
226
+ when Symbol then key.to_s
186
227
  end
187
- buffer
228
+ rescue
229
+ # Unsupported caller keys must not break flag evaluation.
230
+ nil
188
231
  end
232
+ private_class_method :context_key_string
233
+
234
+ def self.bounded_flatten(prefix, value, output, seen, depth, reasons, budget)
235
+ # Charge every node first, before any early return. A node that exits early still
236
+ # cost a call, and the cheap exits (nil leaves especially) are exactly what an
237
+ # adversarial context is made of.
238
+ if budget[0] <= 0
239
+ reasons << REASON_MAX_VISITED_NODES
240
+ return
241
+ end
242
+ budget[0] -= 1
189
243
 
190
- def self.flatten_value(prefix, value, output, seen, depth)
191
- return if depth > MAX_CONTEXT_DEPTH
244
+ return if value.nil?
245
+ if output.size >= MAX_CONTEXT_FIELDS
246
+ reasons << REASON_MAX_CONTEXT_FIELDS
247
+ return
248
+ end
249
+ if depth >= MAX_SNAPSHOT_DEPTH
250
+ reasons << REASON_MAX_SNAPSHOT_DEPTH
251
+ return
252
+ end
253
+ if prefix.length > MAX_KEY_LENGTH
254
+ reasons << REASON_MAX_KEY_LENGTH
255
+ return
256
+ end
192
257
 
193
258
  case value
194
259
  when Hash
260
+ return if value.empty?
261
+
195
262
  object_id = value.object_id
196
- return if seen[object_id]
263
+ if seen[object_id]
264
+ reasons << REASON_CYCLE
265
+ return
266
+ end
197
267
 
198
268
  seen[object_id] = true
269
+ reasons << REASON_MAX_STRUCTURE_PROPERTIES if value.size > MAX_STRUCTURE_PROPERTIES
270
+ walked = 0
199
271
  value.each do |key, child_value|
200
- flatten_value("#{prefix}.#{key}", child_value, output, seen, depth + 1)
272
+ if output.size >= MAX_CONTEXT_FIELDS
273
+ reasons << REASON_MAX_CONTEXT_FIELDS
274
+ break
275
+ end
276
+ break if walked >= MAX_STRUCTURE_PROPERTIES
277
+ if budget[0] <= 0
278
+ reasons << REASON_MAX_VISITED_NODES
279
+ break
280
+ end
281
+
282
+ walked += 1
283
+ child_key = context_key_string(key)
284
+ next unless child_key
285
+ if prefix.length + 1 + child_key.length > MAX_KEY_LENGTH
286
+ reasons << REASON_MAX_KEY_LENGTH
287
+ next
288
+ end
289
+
290
+ child_key = child_key.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
291
+ bounded_flatten("#{prefix}.#{child_key}", child_value, output, seen, depth + 1, reasons, budget)
201
292
  end
202
293
  seen.delete(object_id)
203
294
  when Array
295
+ return if value.empty?
296
+
204
297
  object_id = value.object_id
205
- return if seen[object_id]
298
+ if seen[object_id]
299
+ reasons << REASON_CYCLE
300
+ return
301
+ end
206
302
 
207
303
  seen[object_id] = true
304
+ reasons << REASON_MAX_LIST_ELEMENTS if value.size > MAX_LIST_ELEMENTS
305
+ walked = 0
208
306
  value.each_with_index do |child_value, index|
209
- flatten_value("#{prefix}.#{index}", child_value, output, seen, depth + 1)
307
+ if output.size >= MAX_CONTEXT_FIELDS
308
+ reasons << REASON_MAX_CONTEXT_FIELDS
309
+ break
310
+ end
311
+ break if walked >= MAX_LIST_ELEMENTS
312
+ if budget[0] <= 0
313
+ reasons << REASON_MAX_VISITED_NODES
314
+ break
315
+ end
316
+
317
+ walked += 1
318
+ bounded_flatten("#{prefix}.#{index}", child_value, output, seen, depth + 1, reasons, budget)
210
319
  end
211
320
  seen.delete(object_id)
212
321
  else
213
- output[prefix] = value unless value.nil?
322
+ if value.is_a?(String)
323
+ begin
324
+ string_value = String.new(value)
325
+ if string_value.length > MAX_VALUE_LENGTH
326
+ reasons << REASON_MAX_VALUE_LENGTH
327
+ return
328
+ end
329
+
330
+ output[prefix] = string_value.encode(
331
+ Encoding::UTF_8, invalid: :replace, undef: :replace
332
+ ).freeze
333
+ rescue
334
+ # Unsupported caller values must not break flag evaluation.
335
+ end
336
+ return
337
+ end
338
+
339
+ case value
340
+ when TrueClass, FalseClass, Integer, Float
341
+ output[prefix] = value
342
+ end
214
343
  end
215
344
  end
216
- private_class_method :flatten_value
345
+ private_class_method :bounded_flatten
346
+
347
+ # Canonical context key: sorted type-tagged length-delimited encoding.
348
+ # Each field is: 8-byte big-endian key length + key bytes + type-tag byte +
349
+ # 8-byte big-endian value length + value bytes.
350
+ # No hash digest — the key IS the full encoding (collision-free, no FNV).
351
+ def canonical_context_key(attrs)
352
+ return "" if attrs.nil? || attrs.empty?
353
+
354
+ buffer = String.new("", encoding: Encoding::BINARY)
355
+ attrs.keys.sort.each do |key|
356
+ value = attrs[key]
357
+ buffer << length_delimited(key.to_s)
358
+ buffer << context_value_bytes(value)
359
+ end
360
+ buffer
361
+ end
217
362
 
218
363
  private
219
364
 
@@ -230,7 +375,8 @@ module Datadog
230
375
 
231
376
  # 8-byte big-endian length prefix + raw bytes. Unambiguous field boundary.
232
377
  def length_delimited(string)
233
- bytes = string.encode(Encoding::BINARY, invalid: :replace, undef: :replace)
378
+ utf8 = string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
379
+ bytes = utf8.dup.force_encoding(Encoding::BINARY)
234
380
  byte_length = bytes.bytesize
235
381
  # Build 8-byte big-endian length
236
382
  length_bytes = String.new("", encoding: Encoding::BINARY)
@@ -240,7 +386,10 @@ module Datadog
240
386
  length_bytes + bytes
241
387
  end
242
388
 
243
- def new_entry(evaluation_time_ms, runtime_default:, error_message: nil, targeting_key: nil, context_attrs: nil)
389
+ def new_entry(
390
+ evaluation_time_ms, runtime_default:, observe_full_evaluation_data:, error_message: nil,
391
+ targeting_key: nil, context_attrs: nil
392
+ )
244
393
  {
245
394
  count: 1,
246
395
  first_evaluation: evaluation_time_ms,
@@ -249,20 +398,26 @@ module Datadog
249
398
  error_message: error_message,
250
399
  targeting_key: targeting_key,
251
400
  context_attrs: context_attrs,
401
+ observe_full_evaluation_data: observe_full_evaluation_data,
252
402
  }
253
403
  end
254
404
 
255
- def observe(entry, evaluation_time_ms)
405
+ def observe(entry, evaluation_time_ms, observe_full_evaluation_data)
256
406
  entry[:count] += 1
257
407
  entry[:first_evaluation] = evaluation_time_ms if evaluation_time_ms < entry[:first_evaluation]
258
408
  entry[:last_evaluation] = evaluation_time_ms if evaluation_time_ms > entry[:last_evaluation]
409
+ entry[:observe_full_evaluation_data] &&= observe_full_evaluation_data
259
410
  end
260
411
 
261
- def add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
412
+ def add_to_degraded(
413
+ flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms,
414
+ observe_full_evaluation_data
415
+ )
416
+ # @type var degraded_key: degraded_key
262
417
  degraded_key = [flag_key, variant, allocation_key, runtime_default, error_message]
263
418
 
264
419
  if (entry = @degraded[degraded_key])
265
- observe(entry, evaluation_time_ms)
420
+ observe(entry, evaluation_time_ms, observe_full_evaluation_data)
266
421
  return
267
422
  end
268
423
 
@@ -273,11 +428,12 @@ module Datadog
273
428
  return
274
429
  end
275
430
 
276
- # Degraded entry omits targeting_key + context_attrs (schema omitempty fields)
431
+ # Degraded entries omit targeting_key and context_attrs.
277
432
  @degraded[degraded_key] = new_entry(
278
433
  evaluation_time_ms,
279
434
  runtime_default: runtime_default,
280
- error_message: error_message
435
+ error_message: error_message,
436
+ observe_full_evaluation_data: observe_full_evaluation_data
281
437
  )
282
438
  end
283
439
  end