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
data/lib/datadog/di.rb CHANGED
@@ -71,6 +71,69 @@ module Datadog
71
71
  Datadog.configuration.dynamic_instrumentation.enabled
72
72
  end
73
73
 
74
+ # Returns a human-readable reason why dynamic instrumentation cannot run
75
+ # under the given settings, or nil if all build-time preconditions are met.
76
+ #
77
+ # Single source of truth for the preconditions checked in
78
+ # {Component.build} and reported back by {Remote.handle_rc_enablement}
79
+ # when an implicit enablement signal arrives but the component was not
80
+ # built at startup. Checks are ordered from most-actionable to
81
+ # platform-constraint so the most useful reason wins.
82
+ #
83
+ # The settings argument is optional so the helper can be called from
84
+ # contexts that don't have settings in scope (e.g. the RC handler).
85
+ #
86
+ # @param settings [Datadog::Core::Configuration::Settings]
87
+ # @return [String, nil] reason string or nil when supported
88
+ def unsupported_reason(settings = Datadog.configuration)
89
+ # Symmetric to the respond_to?(:remote) guard below: in unusual
90
+ # configurations (test doubles, partial Settings) the DI namespace
91
+ # may be absent. Returning a reason here lets callers — most
92
+ # importantly Remote.handle_rc_enablement — emit the customer-facing
93
+ # warn instead of raising NoMethodError on the unguarded access at
94
+ # line 92 (`settings.dynamic_instrumentation.internal.development`).
95
+ unless settings.respond_to?(:dynamic_instrumentation)
96
+ return "dynamic instrumentation settings are not available"
97
+ end
98
+ unless settings.respond_to?(:remote) && settings.remote.enabled
99
+ return "Remote Configuration is not enabled. See https://docs.datadoghq.com/agent/remote_config"
100
+ end
101
+ unless settings.dynamic_instrumentation.internal.development
102
+ if Datadog::Core::Environment::Execution.development?
103
+ return "development environment detected"
104
+ end
105
+ end
106
+ if (reason = unsupported_platform_reason)
107
+ return reason
108
+ end
109
+ unless respond_to?(:exception_message)
110
+ return "C extension is not available"
111
+ end
112
+ nil
113
+ end
114
+
115
+ # Whether the current Ruby runtime can run dynamic instrumentation:
116
+ # MRI (CRuby) on Ruby 2.6 or later.
117
+ #
118
+ # @return [Boolean]
119
+ def supported_runtime?
120
+ unsupported_platform_reason.nil?
121
+ end
122
+
123
+ # Reason the current Ruby runtime cannot run dynamic instrumentation, or
124
+ # nil when the platform is supported. Single source of truth for the
125
+ # minimum-runtime definition, shared by {unsupported_reason} (which layers
126
+ # the settings and C-extension checks on top) and {supported_runtime?}.
127
+ #
128
+ # @return [String, nil]
129
+ private def unsupported_platform_reason
130
+ if RUBY_ENGINE != 'ruby'
131
+ "MRI is required, but running on #{RUBY_ENGINE}"
132
+ elsif Datadog::RubyVersion.is?('< 2.6')
133
+ "Ruby 2.6+ is required, but running on #{RUBY_VERSION}"
134
+ end
135
+ end
136
+
74
137
  # Returns iseqs that correspond to loaded files (filtering out eval'd code).
75
138
  #
76
139
  # There are several types of iseqs returned by +all_iseqs+:
@@ -9,14 +9,17 @@ module Datadog
9
9
  module Events
10
10
  # The second version of Business Logic Events SDK
11
11
  module V2
12
+ SIGNUP_EVENT = 'users.signup'
12
13
  LOGIN_SUCCESS_EVENT = 'users.login.success'
13
14
  LOGIN_FAILURE_EVENT = 'users.login.failure'
15
+
14
16
  TELEMETRY_METRICS_NAMESPACE = 'appsec'
15
17
  TELEMETRY_METRICS_SDK_EVENT = 'sdk.event'
16
18
  TELEMETRY_METRICS_SDK_VERSION = 'v2'
17
19
  TELEMETRY_METRICS_EVENTS_INTO_TYPES = {
18
20
  LOGIN_SUCCESS_EVENT => 'login_success',
19
- LOGIN_FAILURE_EVENT => 'login_failure'
21
+ LOGIN_FAILURE_EVENT => 'login_failure',
22
+ SIGNUP_EVENT => 'signup'
20
23
  }.freeze
21
24
 
22
25
  class << self
@@ -125,6 +128,61 @@ module Datadog
125
128
  ::Datadog::AppSec::Instrumentation.gateway.push('identity.set_user', user)
126
129
  end
127
130
 
131
+ # Attach user signup information to the service entry span
132
+ # and trigger AppSec event processing.
133
+ #
134
+ # @param login [String] The user login (e.g., username or email).
135
+ # @param user_or_id [String, Hash<Symbol, String>] (optional) If a
136
+ # String, considered as a user ID, if a Hash, considered as a user
137
+ # attributes. The Hash must include `:id` as a key.
138
+ # @param metadata [Hash<Symbol, String>] Additional flat free-form
139
+ # metadata to attach to the event.
140
+ #
141
+ # @example Login only
142
+ # Datadog::Kit::AppSec::Events::V2.track_user_signup('alice@example.com')
143
+ #
144
+ # @example Login, user attributes, and metadata
145
+ # Datadog::Kit::AppSec::Events::V2.track_user_signup(
146
+ # 'alice@example.com',
147
+ # { id: 'user-123', email: 'alice@example.com', name: 'Alice' },
148
+ # ip: '192.168.1.1', device: 'mobile', 'usr.country': 'US'
149
+ # )
150
+ #
151
+ # @return [void]
152
+ def track_user_signup(login, user_or_id = nil, metadata = {})
153
+ trace = service_entry_trace
154
+ span = service_entry_span
155
+
156
+ if trace.nil? || span.nil?
157
+ return Datadog.logger.warn(
158
+ 'Kit::AppSec: Tracing is not enabled. Please enable tracing if you want to track events'
159
+ )
160
+ end
161
+
162
+ raise TypeError, '`login` argument must be a String' unless login.is_a?(String)
163
+ raise TypeError, '`metadata` argument must be a Hash' unless metadata.is_a?(Hash)
164
+
165
+ user_attributes = build_user_attributes(user_or_id, login)
166
+
167
+ set_span_tags(span, metadata, namespace: SIGNUP_EVENT)
168
+ set_span_tags(span, user_attributes, namespace: "#{SIGNUP_EVENT}.usr")
169
+ span.set_tag('appsec.events.users.signup.track', 'true')
170
+ span.set_tag('_dd.appsec.events.users.signup.sdk', 'true')
171
+
172
+ ::Datadog::AppSec::TraceKeeper.keep!(trace)
173
+
174
+ record_event_telemetry_metric(SIGNUP_EVENT)
175
+
176
+ if user_attributes.key?(:id)
177
+ Kit::Identity.set_user(trace, span, **user_attributes)
178
+ else
179
+ # NOTE: {Datadog::Kit::Identity.set_user} requires an ID,
180
+ # but WAF can evaluate login alone
181
+ user = ::Datadog::AppSec::Instrumentation::Gateway::User.new(nil, login)
182
+ ::Datadog::AppSec::Instrumentation.gateway.push('identity.set_user', user)
183
+ end
184
+ end
185
+
128
186
  private
129
187
 
130
188
  # NOTE: Current tracer implementation does not provide a way to
@@ -155,7 +213,7 @@ module Datadog
155
213
  raise ArgumentError, 'missing required user key `:id`' unless user_or_id.key?(:id)
156
214
  raise TypeError, 'user key `:id` must be a String' unless user_or_id[:id].is_a?(String)
157
215
 
158
- user_or_id.merge(login: login) #: {login: ::String, ?id: ::String?}
216
+ user_or_id.merge(login: login)
159
217
  else
160
218
  raise TypeError, '`user_or_id` argument must be either String or Hash'
161
219
  end
@@ -12,7 +12,7 @@ module Datadog
12
12
  module OpenFeature
13
13
  # This class is the entry point for the OpenFeature component
14
14
  class Component
15
- attr_reader :engine, :flag_eval_hook
15
+ attr_reader :engine, :flag_eval_metrics_hook, :flag_eval_evp_hook
16
16
 
17
17
  def self.build(settings, agent_settings, logger:, telemetry:)
18
18
  return unless settings.respond_to?(:open_feature) && settings.open_feature.enabled
@@ -54,21 +54,44 @@ module Datadog
54
54
 
55
55
  @telemetry = telemetry
56
56
  @logger = logger
57
- @flag_eval_hook = create_flag_eval_hook
57
+ @settings = settings
58
+ @agent_settings = agent_settings
59
+ @flag_eval_metrics_hook = create_flag_eval_metrics_hook
60
+ @flag_eval_evp_hook = create_flag_eval_evp_hook
58
61
  end
59
62
 
60
63
  def shutdown!
61
64
  @worker.graceful_shutdown
65
+ @flag_eval_evp_writer&.stop
62
66
  end
63
67
 
64
68
  private
65
69
 
66
- def create_flag_eval_hook
67
- require_relative 'hooks/flag_eval_hook'
68
- return unless Hooks::FlagEvalHook.available?
70
+ def create_flag_eval_metrics_hook
71
+ require_relative 'hooks/flag_eval_metrics_hook'
72
+ return unless Hooks::FlagEvalMetricsHook.available?
69
73
 
70
74
  metrics = Metrics::FlagEvalMetrics.new(telemetry: @telemetry, logger: @logger)
71
- Hooks::FlagEvalHook.new(metrics)
75
+ Hooks::FlagEvalMetricsHook.new(metrics)
76
+ rescue LoadError
77
+ nil
78
+ end
79
+
80
+ # Killswitch: DD_FLAGGING_EVALUATION_COUNTS_ENABLED (default on) gates only the EVP path.
81
+ # Read through the datadog config registry, not raw ENV.
82
+ def create_flag_eval_evp_hook
83
+ return unless @settings.open_feature.evaluation_counts_enabled
84
+
85
+ require_relative 'hooks/flag_eval_evp_hook'
86
+ return unless Hooks::FlagEvalEVPHook.available?
87
+
88
+ evp_transport = Transport::HTTP.build_flagevaluations(
89
+ agent_settings: @agent_settings,
90
+ logger: @logger,
91
+ )
92
+ require_relative 'flag_evaluation/writer'
93
+ @flag_eval_evp_writer = FlagEvaluation::Writer.new(transport: evp_transport, logger: @logger, telemetry: @telemetry)
94
+ Hooks::FlagEvalEVPHook.new(@flag_eval_evp_writer)
72
95
  rescue LoadError
73
96
  nil
74
97
  end
@@ -20,6 +20,14 @@ module Datadog
20
20
  o.env 'DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED'
21
21
  o.default false
22
22
  end
23
+
24
+ # Killswitch for the EVP `flagevaluation` emission path only. Default on; when
25
+ # disabled the existing OTel `feature_flag.evaluations` metric is unaffected.
26
+ option :evaluation_counts_enabled do |o|
27
+ o.type :bool
28
+ o.env 'DD_FLAGGING_EVALUATION_COUNTS_ENABLED'
29
+ o.default true
30
+ end
23
31
  end
24
32
  end
25
33
  end
@@ -4,8 +4,10 @@ module Datadog
4
4
  module OpenFeature
5
5
  module Ext
6
6
  ERROR = 'ERROR'
7
+ DEFAULT = 'DEFAULT'
7
8
  INITIALIZING = 'INITIALIZING'
8
9
  UNKNOWN_TYPE = 'UNKNOWN_TYPE'
10
+ PARSE_ERROR = 'PARSE_ERROR'
9
11
  GENERAL = 'GENERAL'
10
12
  PROVIDER_FATAL = 'PROVIDER_FATAL'
11
13
  PROVIDER_NOT_READY = 'PROVIDER_NOT_READY'
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module OpenFeature
5
+ module FlagEvaluation
6
+ # Two-tier aggregation for EVP flagevaluation events.
7
+ #
8
+ # Two-tier design:
9
+ # - full-tier key: (flag_key, variant, allocation_key, runtime_default, error_message, targeting_key, canonical_context_key)
10
+ # - degraded-tier key: (flag_key, variant, allocation_key, runtime_default, error_message)
11
+ # - Drop-and-count when degraded tier is full
12
+ # - canonical_context_key: sorted type-tagged length-delimited encoding (no hash digest)
13
+ # - 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)
15
+ class Aggregator
16
+ MAX_CONTEXT_FIELDS = 256
17
+ MAX_FIELD_LENGTH = 256
18
+ MAX_CONTEXT_DEPTH = 32
19
+
20
+ # Type tags so values of different Ruby types never collide in the canonical key.
21
+ CTX_TAG_STRING = 's'
22
+ CTX_TAG_BOOL = 'b'
23
+ CTX_TAG_INTEGER = 'i'
24
+ CTX_TAG_FLOAT = 'f'
25
+ CTX_TAG_OTHER = 'o'
26
+
27
+ EVAL_SCALE_TARGET_FLAGS = 2_500
28
+ EVAL_SCALE_FULL_BUCKETS_PER_FLAG = 50
29
+ EVAL_SCALE_USERS_PER_FLAG = 1_000
30
+ EVAL_SCALE_PER_FLAG_HEADROOM_MULTIPLIER = 10
31
+ EVAL_SCALE_DEGRADED_BUCKETS_PER_FLAG = 10
32
+ EVAL_SCALE_FULL_BUCKET_TARGET = EVAL_SCALE_TARGET_FLAGS * EVAL_SCALE_FULL_BUCKETS_PER_FLAG
33
+ EVAL_SCALE_PER_FLAG_BUCKET_TARGET = EVAL_SCALE_PER_FLAG_HEADROOM_MULTIPLIER * EVAL_SCALE_USERS_PER_FLAG
34
+ EVAL_SCALE_DEGRADED_BUCKET_TARGET = EVAL_SCALE_TARGET_FLAGS * EVAL_SCALE_DEGRADED_BUCKETS_PER_FLAG
35
+
36
+ DEFAULT_GLOBAL_CAP = 131_072
37
+ DEFAULT_PER_FLAG_CAP = EVAL_SCALE_PER_FLAG_BUCKET_TARGET
38
+ DEFAULT_DEGRADED_CAP = 32_768
39
+
40
+ attr_reader :dropped_degraded_overflow
41
+
42
+ def initialize(
43
+ global_cap: DEFAULT_GLOBAL_CAP,
44
+ per_flag_cap: DEFAULT_PER_FLAG_CAP,
45
+ degraded_cap: DEFAULT_DEGRADED_CAP
46
+ )
47
+ @global_cap = global_cap
48
+ @per_flag_cap = per_flag_cap
49
+ @degraded_cap = degraded_cap
50
+
51
+ @mutex = Mutex.new
52
+ # full-tier: Array key -> Hash entry
53
+ @full = {}
54
+ # degraded-tier: Array key -> Hash entry
55
+ @degraded = {}
56
+ # per-flag full-bucket count for per_flag_cap enforcement
57
+ @per_flag_full = Hash.new(0)
58
+ @global_count = 0
59
+ @dropped_degraded_overflow = 0
60
+ end
61
+
62
+ # Record one evaluation event. Thread-safe. Called from the background writer.
63
+ def record(
64
+ flag_key:, variant:, allocation_key:, targeting_key:, eval_time_ms:, attrs:, error_message: nil,
65
+ runtime_default: nil
66
+ )
67
+ runtime_default = variant.nil? if runtime_default.nil?
68
+ runtime_default = !!runtime_default
69
+
70
+ # Normalize nil/empty strings
71
+ variant = variant.to_s
72
+ allocation_key = allocation_key.to_s
73
+ 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]
82
+ evaluation_time_ms = eval_time_ms.to_i
83
+
84
+ @mutex.synchronize do
85
+ # --- Full tier ---
86
+ if (entry = @full[full_key])
87
+ observe(entry, evaluation_time_ms)
88
+ return
89
+ end
90
+
91
+ per_flag_count = @per_flag_full[flag_key]
92
+ if per_flag_count >= @per_flag_cap
93
+ add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
94
+ return
95
+ end
96
+
97
+ # Count the full-tier attempt before checking the global cap so per-flag overflow stays
98
+ # active even when the global full-tier cap is already saturated.
99
+ @per_flag_full[flag_key] = per_flag_count + 1
100
+
101
+ if @global_count < @global_cap
102
+ entry = new_entry(
103
+ evaluation_time_ms,
104
+ runtime_default: runtime_default,
105
+ error_message: error_message,
106
+ targeting_key: targeting_key,
107
+ context_attrs: pruned_context
108
+ )
109
+ @full[full_key] = entry
110
+ @global_count += 1
111
+ else
112
+ # Route to degraded tier
113
+ add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
114
+ end
115
+ end
116
+ end
117
+
118
+ # 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).
122
+ def flush_and_reset
123
+ @mutex.synchronize do
124
+ full_snapshot = @full
125
+ degraded_snapshot = @degraded
126
+ dropped_snapshot = @dropped_degraded_overflow
127
+
128
+ @full = {}
129
+ @degraded = {}
130
+ @per_flag_full = Hash.new(0)
131
+ @global_count = 0
132
+ @dropped_degraded_overflow = 0
133
+
134
+ {full: full_snapshot, degraded: degraded_snapshot, dropped_degraded_overflow: dropped_snapshot}
135
+ end
136
+ end
137
+
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 = {}
167
+ seen = {attrs.object_id => true}
168
+ attrs.each do |key, value|
169
+ flatten_value(key.to_s, value, flattened_context, seen, 0)
170
+ end
171
+ flattened_context
172
+ end
173
+
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)
186
+ end
187
+ buffer
188
+ end
189
+
190
+ def self.flatten_value(prefix, value, output, seen, depth)
191
+ return if depth > MAX_CONTEXT_DEPTH
192
+
193
+ case value
194
+ when Hash
195
+ object_id = value.object_id
196
+ return if seen[object_id]
197
+
198
+ seen[object_id] = true
199
+ value.each do |key, child_value|
200
+ flatten_value("#{prefix}.#{key}", child_value, output, seen, depth + 1)
201
+ end
202
+ seen.delete(object_id)
203
+ when Array
204
+ object_id = value.object_id
205
+ return if seen[object_id]
206
+
207
+ seen[object_id] = true
208
+ value.each_with_index do |child_value, index|
209
+ flatten_value("#{prefix}.#{index}", child_value, output, seen, depth + 1)
210
+ end
211
+ seen.delete(object_id)
212
+ else
213
+ output[prefix] = value unless value.nil?
214
+ end
215
+ end
216
+ private_class_method :flatten_value
217
+
218
+ private
219
+
220
+ def context_value_bytes(value)
221
+ tag, encoded = case value
222
+ when String then [CTX_TAG_STRING, value.to_s]
223
+ when TrueClass, FalseClass then [CTX_TAG_BOOL, value.to_s]
224
+ when Integer then [CTX_TAG_INTEGER, value.to_s]
225
+ when Float then [CTX_TAG_FLOAT, value.to_s]
226
+ else [CTX_TAG_OTHER, value.to_s]
227
+ end
228
+ String.new(tag, encoding: Encoding::BINARY) + length_delimited(encoded)
229
+ end
230
+
231
+ # 8-byte big-endian length prefix + raw bytes. Unambiguous field boundary.
232
+ def length_delimited(string)
233
+ bytes = string.encode(Encoding::BINARY, invalid: :replace, undef: :replace)
234
+ byte_length = bytes.bytesize
235
+ # Build 8-byte big-endian length
236
+ length_bytes = String.new('', encoding: Encoding::BINARY)
237
+ 8.times do |index|
238
+ length_bytes.prepend(((byte_length >> (8 * index)) & 0xFF).chr(Encoding::BINARY))
239
+ end
240
+ length_bytes + bytes
241
+ end
242
+
243
+ def new_entry(evaluation_time_ms, runtime_default:, error_message: nil, targeting_key: nil, context_attrs: nil)
244
+ {
245
+ count: 1,
246
+ first_evaluation: evaluation_time_ms,
247
+ last_evaluation: evaluation_time_ms,
248
+ runtime_default: runtime_default,
249
+ error_message: error_message,
250
+ targeting_key: targeting_key,
251
+ context_attrs: context_attrs,
252
+ }
253
+ end
254
+
255
+ def observe(entry, evaluation_time_ms)
256
+ entry[:count] += 1
257
+ entry[:first_evaluation] = evaluation_time_ms if evaluation_time_ms < entry[:first_evaluation]
258
+ entry[:last_evaluation] = evaluation_time_ms if evaluation_time_ms > entry[:last_evaluation]
259
+ end
260
+
261
+ def add_to_degraded(flag_key, variant, allocation_key, runtime_default, error_message, evaluation_time_ms)
262
+ degraded_key = [flag_key, variant, allocation_key, runtime_default, error_message]
263
+
264
+ if (entry = @degraded[degraded_key])
265
+ observe(entry, evaluation_time_ms)
266
+ return
267
+ end
268
+
269
+ # New degraded bucket — check degraded_cap (terminal tier)
270
+ if @degraded.size >= @degraded_cap
271
+ # Terminal tier full — drop and count (explicit overflow counter)
272
+ @dropped_degraded_overflow += 1
273
+ return
274
+ end
275
+
276
+ # Degraded entry omits targeting_key + context_attrs (schema omitempty fields)
277
+ @degraded[degraded_key] = new_entry(
278
+ evaluation_time_ms,
279
+ runtime_default: runtime_default,
280
+ error_message: error_message
281
+ )
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end