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
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)
|
|
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, :
|
|
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
|
-
@
|
|
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
|
|
67
|
-
require_relative 'hooks/
|
|
68
|
-
return unless Hooks::
|
|
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::
|
|
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
|