datadog 2.43.0 → 2.45.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +136 -132
  4. data/ext/datadog_profiling_native_extension/collectors_discrete_dynamic_sampler.c +1 -5
  5. data/ext/datadog_profiling_native_extension/collectors_gc_profiling_helper.c +0 -2
  6. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +19 -20
  7. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.h +5 -1
  8. data/ext/datadog_profiling_native_extension/collectors_stack.c +7 -7
  9. data/ext/datadog_profiling_native_extension/collectors_stack.h +1 -1
  10. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +146 -109
  11. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +3 -2
  12. data/ext/datadog_profiling_native_extension/extconf.rb +6 -2
  13. data/ext/datadog_profiling_native_extension/heap_recorder.c +2 -2
  14. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +122 -53
  15. data/ext/datadog_profiling_native_extension/private_vm_api_access.h +6 -2
  16. data/ext/datadog_profiling_native_extension/profiling.c +22 -23
  17. data/ext/datadog_profiling_native_extension/ruby_helpers.c +3 -1
  18. data/ext/datadog_profiling_native_extension/ruby_helpers.h +4 -0
  19. data/ext/datadog_profiling_native_extension/setup_signal_handler.c +0 -2
  20. data/ext/datadog_profiling_native_extension/stack_recorder.c +10 -28
  21. data/ext/datadog_profiling_native_extension/stack_recorder.h +0 -1
  22. data/ext/datadog_profiling_native_extension/time_helpers.c +2 -4
  23. data/ext/datadog_profiling_native_extension/time_helpers.h +13 -14
  24. data/ext/libdatadog_api/di.c +8 -3
  25. data/ext/libdatadog_api/extconf.rb +1 -1
  26. data/ext/libdatadog_api/feature_flags.c +8 -0
  27. data/ext/libdatadog_api/init.c +3 -1
  28. data/ext/libdatadog_api/otel_thread_context.c +38 -17
  29. data/ext/libdatadog_api/trace_exporter.c +77 -11
  30. data/ext/libdatadog_extconf_helpers.rb +14 -9
  31. data/lib/datadog/ai_guard/component.rb +17 -8
  32. data/lib/datadog/ai_guard/configuration/ext.rb +2 -0
  33. data/lib/datadog/ai_guard/configuration.rb +13 -0
  34. data/lib/datadog/ai_guard/contrib/ruby_llm/chat_instrumentation.rb +48 -55
  35. data/lib/datadog/ai_guard/contrib/ruby_llm/integration.rb +1 -1
  36. data/lib/datadog/ai_guard/contrib/ruby_llm/message_adapter.rb +76 -0
  37. data/lib/datadog/ai_guard/contrib/ruby_llm/patcher.rb +1 -0
  38. data/lib/datadog/ai_guard/contrib/ruby_llm/redaction_change_set.rb +135 -0
  39. data/lib/datadog/ai_guard/evaluation/client.rb +50 -0
  40. data/lib/datadog/ai_guard/evaluation/content_part.rb +12 -0
  41. data/lib/datadog/ai_guard/evaluation/message.rb +25 -7
  42. data/lib/datadog/ai_guard/evaluation/message_builder.rb +31 -0
  43. data/lib/datadog/ai_guard/evaluation/no_op_result.rb +6 -6
  44. data/lib/datadog/ai_guard/evaluation/outcome.rb +24 -0
  45. data/lib/datadog/ai_guard/evaluation/request.rb +4 -60
  46. data/lib/datadog/ai_guard/evaluation/response.rb +33 -0
  47. data/lib/datadog/ai_guard/evaluation/result.rb +11 -18
  48. data/lib/datadog/ai_guard/evaluation/tool_call.rb +15 -2
  49. data/lib/datadog/ai_guard/evaluation.rb +25 -11
  50. data/lib/datadog/ai_guard/ext.rb +1 -0
  51. data/lib/datadog/ai_guard/{api_client.rb → http_client.rb} +6 -4
  52. data/lib/datadog/ai_guard/metrics/telemetry.rb +43 -0
  53. data/lib/datadog/ai_guard/redaction/replacements.rb +97 -0
  54. data/lib/datadog/ai_guard/redaction/result.rb +29 -0
  55. data/lib/datadog/ai_guard/redaction.rb +80 -0
  56. data/lib/datadog/ai_guard.rb +58 -57
  57. data/lib/datadog/core/configuration/components.rb +9 -1
  58. data/lib/datadog/core/configuration/settings.rb +5 -2
  59. data/lib/datadog/core/configuration/supported_configurations.rb +8 -0
  60. data/lib/datadog/core/remote/client/capabilities.rb +10 -4
  61. data/lib/datadog/core/remote/component.rb +9 -2
  62. data/lib/datadog/core/remote/worker.rb +8 -0
  63. data/lib/datadog/di/instrumenter.rb +1 -1
  64. data/lib/datadog/di/probe_manager.rb +10 -5
  65. data/lib/datadog/di/proc_responder.rb +1 -1
  66. data/lib/datadog/open_feature/agentless/configuration_source.rb +246 -0
  67. data/lib/datadog/open_feature/agentless/parser.rb +43 -0
  68. data/lib/datadog/open_feature/agentless/response.rb +19 -0
  69. data/lib/datadog/open_feature/agentless/transport.rb +103 -0
  70. data/lib/datadog/open_feature/component.rb +57 -1
  71. data/lib/datadog/open_feature/configuration/agentless_endpoint.rb +90 -0
  72. data/lib/datadog/open_feature/configuration/source/resolution.rb +22 -0
  73. data/lib/datadog/open_feature/configuration/source.rb +43 -0
  74. data/lib/datadog/open_feature/configuration.rb +118 -0
  75. data/lib/datadog/open_feature/native_evaluator.rb +1 -14
  76. data/lib/datadog/open_feature/remote.rb +5 -5
  77. data/lib/datadog/opentelemetry/signal_configuration.rb +13 -9
  78. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +7 -2
  79. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -7
  80. data/lib/datadog/profiling/component.rb +2 -3
  81. data/lib/datadog/profiling/exporter.rb +7 -5
  82. data/lib/datadog/symbol_database/extractor.rb +84 -102
  83. data/lib/datadog/tracing/component.rb +3 -1
  84. data/lib/datadog/tracing/configuration/ext.rb +1 -0
  85. data/lib/datadog/tracing/configuration/settings.rb +9 -2
  86. data/lib/datadog/tracing/context.rb +24 -2
  87. data/lib/datadog/tracing/context_provider.rb +9 -5
  88. data/lib/datadog/tracing/contrib/grape/endpoint.rb +32 -5
  89. data/lib/datadog/tracing/contrib/utils/quantization/http.rb +6 -3
  90. data/lib/datadog/tracing/diagnostics/environment_logger.rb +1 -0
  91. data/lib/datadog/tracing/metadata/ext.rb +3 -0
  92. data/lib/datadog/tracing/otel_thread_context.rb +76 -11
  93. data/lib/datadog/tracing/tracer.rb +15 -2
  94. data/lib/datadog/tracing/transport/native.rb +23 -22
  95. data/lib/datadog/tracing/transport/trace_formatter.rb +13 -3
  96. data/lib/datadog/tracing/transport/traces.rb +1 -1
  97. data/lib/datadog/version.rb +1 -1
  98. metadata +23 -7
  99. data/lib/datadog/ai_guard/evaluation/content_builder.rb +0 -31
@@ -757,12 +757,15 @@ module Datadog
757
757
  val ||= ""
758
758
  # maps OpenTelemetry semantic attributes to Datadog tags
759
759
  key = case key.downcase
760
- when "deployment.environment" then "env"
760
+ when "deployment.environment.name"
761
+ result["env"] = val
762
+ nil
763
+ when "deployment.environment" then "env" unless result.key?("env")
761
764
  when "service.version" then "version"
762
765
  when "service.name" then "service"
763
766
  else key
764
767
  end
765
- result[key] = val unless key.empty?
768
+ result[key] = val unless key.nil? || key.empty?
766
769
  end
767
770
  end
768
771
  result
@@ -14,6 +14,7 @@ module Datadog
14
14
  "DD_AI_GUARD_ENDPOINT",
15
15
  "DD_AI_GUARD_MAX_CONTENT_SIZE",
16
16
  "DD_AI_GUARD_MAX_MESSAGES_LENGTH",
17
+ "DD_AI_GUARD_REDACTION_ENABLED",
17
18
  "DD_AI_GUARD_TIMEOUT",
18
19
  "DD_API_KEY",
19
20
  "DD_API_SECURITY_DOWNSTREAM_BODY_ANALYSIS_SAMPLE_RATE",
@@ -60,10 +61,16 @@ module Datadog
60
61
  "DD_ERROR_TRACKING_HANDLED_ERRORS",
61
62
  "DD_ERROR_TRACKING_HANDLED_ERRORS_INCLUDE",
62
63
  "DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED",
64
+ "DD_EXPERIMENTAL_FLAGGING_PROVIDER_INITIALIZATION_TIMEOUT_MS",
63
65
  "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED",
64
66
  "DD_EXPERIMENTAL_NATIVE_TRANSPORT_ENABLED",
65
67
  "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED",
66
68
  "DD_EXTERNAL_ENV",
69
+ "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE",
70
+ "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL",
71
+ "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS",
72
+ "DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS",
73
+ "DD_FEATURE_FLAGS_ENABLED",
67
74
  "DD_FLAGGING_EVALUATION_COUNTS_ENABLED",
68
75
  "DD_GIT_COMMIT_SHA",
69
76
  "DD_GIT_REPOSITORY_URL",
@@ -266,6 +273,7 @@ module Datadog
266
273
  "DD_TRACE_OPENSEARCH_PEER_SERVICE",
267
274
  "DD_TRACE_OPENSEARCH_RESOURCE_PATTERN",
268
275
  "DD_TRACE_OPENSEARCH_SERVICE_NAME",
276
+ "DD_TRACE_OTEL_CTX_ENABLED",
269
277
  "DD_TRACE_PEER_SERVICE_DEFAULTS_ENABLED",
270
278
  "DD_TRACE_PEER_SERVICE_MAPPING",
271
279
  "DD_TRACE_PG_ANALYTICS_ENABLED",
@@ -16,14 +16,15 @@ module Datadog
16
16
  class Capabilities
17
17
  attr_reader :capabilities, :receivers, :base64_capabilities
18
18
 
19
- def initialize(settings, telemetry)
19
+ def initialize(settings, telemetry, open_feature_component_provider: nil)
20
+ open_feature_component_provider ||= -> {}
20
21
  @capabilities = []
21
22
  @products = []
22
23
  @products_mutex = Mutex.new
23
24
  @receivers = []
24
25
  @telemetry = telemetry
25
26
 
26
- register(settings)
27
+ register(settings, open_feature_component_provider)
27
28
 
28
29
  @base64_capabilities = capabilities_to_base64
29
30
  end
@@ -46,7 +47,7 @@ module Datadog
46
47
 
47
48
  private
48
49
 
49
- def register(settings)
50
+ def register(settings, open_feature_component_provider)
50
51
  if settings.respond_to?(:appsec) && settings.appsec.enabled
51
52
  register_capabilities(Datadog::AppSec::Remote.capabilities)
52
53
  register_products(Datadog::AppSec::Remote.products)
@@ -98,7 +99,12 @@ module Datadog
98
99
  if settings.respond_to?(:open_feature) && settings.open_feature.enabled
99
100
  register_capabilities(Datadog::OpenFeature::Remote.capabilities)
100
101
  register_products(Datadog::OpenFeature::Remote.products)
101
- register_receivers(Datadog::OpenFeature::Remote.receivers(@telemetry))
102
+ register_receivers(
103
+ Datadog::OpenFeature::Remote.receivers(
104
+ @telemetry,
105
+ component_provider: open_feature_component_provider,
106
+ ),
107
+ )
102
108
  end
103
109
  end
104
110
 
@@ -96,6 +96,8 @@ module Datadog
96
96
  # Recreates the remote configuration client after a fork.
97
97
  # This ensures each forked process has a unique client ID and fresh state.
98
98
  def after_fork
99
+ @worker.after_fork
100
+ @barrier = Barrier.new(@settings.remote.boot_timeout_seconds)
99
101
  @client = Client.new(@transport, @capabilities, settings: @settings, logger: @logger)
100
102
  @healthy = false
101
103
  logger.debug { "remote configuration client recreated after fork: #{@client.id} products: #{@capabilities.products.sort.join(", ")}" }
@@ -187,10 +189,15 @@ module Datadog
187
189
  #
188
190
  # Those checks are instead performed inside the worker loop.
189
191
  # This allows users to upgrade their agent while keeping their application running.
190
- def build(settings, agent_settings, logger:, telemetry:)
192
+ def build(settings, agent_settings, logger:, telemetry:, open_feature_component_provider: nil)
191
193
  return unless settings.remote.enabled
192
194
 
193
- new(settings, Client::Capabilities.new(settings, telemetry), agent_settings, logger: logger)
195
+ capabilities = Client::Capabilities.new(
196
+ settings,
197
+ telemetry,
198
+ open_feature_component_provider: open_feature_component_provider,
199
+ )
200
+ new(settings, capabilities, agent_settings, logger: logger)
194
201
  end
195
202
  end
196
203
  end
@@ -70,6 +70,14 @@ module Datadog
70
70
  @started
71
71
  end
72
72
 
73
+ # Child-only: discard inherited synchronization without starting a thread.
74
+ def after_fork
75
+ @mutex = Mutex.new
76
+ @thr = nil
77
+ @starting = false
78
+ @started = false
79
+ end
80
+
73
81
  private
74
82
 
75
83
  def poll(interval)
@@ -538,7 +538,7 @@ module Datadog
538
538
  # the context, we won't be able to report anything as
539
539
  # the probe notifier builder requires a context.
540
540
  begin
541
- responder.probe_condition_evaluation_failed_callback(context, exc)
541
+ responder.probe_condition_evaluation_failed_callback(context, condition, exc)
542
542
  rescue Exception => nested_exc # standard:disable Lint/RescueException
543
543
  Datadog::DI.reraise_if_fatal(nested_exc)
544
544
  raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions
@@ -310,17 +310,22 @@ module Datadog
310
310
  #
311
311
  # This can happen when the expression references undefined variables,
312
312
  # has type mismatches, or encounters runtime errors during evaluation.
313
- # Rate-limited to 1 notification per second per probe to avoid
314
- # flooding the backend when conditions fail repeatedly.
313
+ # Rate-limited to 1 notification per second per probe, and subject to
314
+ # the process-wide global snapshot rate limit, to avoid flooding the
315
+ # backend when conditions fail repeatedly.
315
316
  #
316
317
  # @param context [Context] The execution context containing probe and captured data
317
- # @param expr [String] The condition expression that failed
318
+ # @param expr [EL::Expression] The condition expression that failed
318
319
  # @param exc [Exception] The exception raised during condition evaluation
319
320
  def probe_condition_evaluation_failed_callback(context, expr, exc)
320
321
  probe = context.probe
321
322
  if probe.condition_evaluation_failed_rate_limiter&.allow?
322
- payload = probe_notification_builder.build_condition_evaluation_failed(context, expr, exc)
323
- probe_notifier_worker.add_snapshot(payload)
323
+ if instrumenter.global_snapshot_rate_limiter.allow?
324
+ payload = probe_notification_builder.build_condition_evaluation_failed(context, expr, exc)
325
+ probe_notifier_worker.add_snapshot(payload)
326
+ else
327
+ logger.trace { "di: #{probe.type} probe #{probe.id}: skipping condition error snapshot due to global rate limit" }
328
+ end
324
329
  end
325
330
  end
326
331
 
@@ -20,7 +20,7 @@ module Datadog
20
20
  executed_proc.call(context)
21
21
  end
22
22
 
23
- def probe_condition_evaluation_failed_callback(context, exc)
23
+ def probe_condition_evaluation_failed_callback(context, _expr, exc)
24
24
  if failed_proc.nil?
25
25
  raise NotImplementedError, "Failed proc not provided"
26
26
  end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../core/workers/polling"
4
+ require_relative "../evaluation_engine"
5
+ require_relative "parser"
6
+ require_relative "transport"
7
+
8
+ module Datadog
9
+ module OpenFeature
10
+ module Agentless
11
+ # Polls the agentless endpoint and applies accepted configuration.
12
+ class ConfigurationSource
13
+ MAX_ATTEMPTS = 3
14
+ FIRST_RETRY_MIN_SECONDS = 2.0
15
+ FIRST_RETRY_MAX_SECONDS = 10.0
16
+ SECOND_RETRY_MIN_SECONDS = 5.0
17
+ SECOND_RETRY_MAX_SECONDS = 30.0
18
+ RETRY_JITTER = 0.2
19
+
20
+ include Core::Workers::Polling
21
+
22
+ attr_reader :etag
23
+
24
+ def self.build(settings, endpoint:, apply:, logger: Datadog.logger)
25
+ api_key = settings.api_key.to_s.strip
26
+ if endpoint.managed? && api_key.empty?
27
+ logger.warn("Feature Flags agentless delivery requires DD_API_KEY; agentless delivery is disabled")
28
+ return
29
+ end
30
+
31
+ new(
32
+ endpoint: endpoint,
33
+ api_key: api_key.empty? ? nil : api_key,
34
+ poll_interval_seconds: settings.feature_flags.agentless.poll_interval_seconds,
35
+ request_timeout_seconds: settings.feature_flags.agentless.request_timeout_seconds,
36
+ apply: apply,
37
+ logger: logger,
38
+ )
39
+ end
40
+
41
+ def initialize(
42
+ endpoint:,
43
+ api_key:,
44
+ poll_interval_seconds:,
45
+ request_timeout_seconds:,
46
+ apply:,
47
+ logger:,
48
+ transport: nil,
49
+ random: nil,
50
+ retry_wait: nil
51
+ )
52
+ @transport = transport || Transport.new(
53
+ endpoint: endpoint,
54
+ api_key: api_key,
55
+ timeout_seconds: request_timeout_seconds,
56
+ )
57
+ @apply = apply
58
+ @logger = logger
59
+ @poll_interval_seconds = poll_interval_seconds
60
+ @random = random || -> { Kernel.rand }
61
+ @retry_wait = retry_wait || ->(delay) { wait_for_retry(delay) }
62
+ @etag = nil
63
+ @warned = {}
64
+ @lifecycle_mutex = Mutex.new
65
+ @started = false
66
+ @stopped = false
67
+
68
+ self.enabled = true
69
+ self.fork_policy = Core::Workers::Async::Thread::FORK_POLICY_RESTART
70
+ self.loop_base_interval = poll_interval_seconds
71
+ end
72
+
73
+ def start
74
+ @lifecycle_mutex.synchronize do
75
+ return false if @stopped
76
+ return true if @started && !forked?
77
+
78
+ perform
79
+ @started = true
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ def stop(force_stop = true, timeout = Core::Workers::Polling::DEFAULT_SHUTDOWN_TIMEOUT)
86
+ @lifecycle_mutex.synchronize do
87
+ return false if @stopped
88
+
89
+ @stopped = true
90
+ self.enabled = false
91
+ end
92
+
93
+ super
94
+ true
95
+ end
96
+
97
+ def perform
98
+ poll
99
+ # An exception escaping here ends the unsupervised worker thread and freezes configuration.
100
+ rescue => error
101
+ warn_once(:unexpected) do
102
+ @logger.error("Feature Flags agentless poll failed with #{error.class}; polling continues")
103
+ end
104
+ end
105
+
106
+ def poll
107
+ attempt = 1
108
+
109
+ while attempt <= MAX_ATTEMPTS
110
+ return if stopped?
111
+
112
+ response = @transport.get(@etag)
113
+ unless retryable?(response)
114
+ apply_response(response)
115
+ return
116
+ end
117
+
118
+ if attempt == MAX_ATTEMPTS
119
+ warn_failure(response, attempt)
120
+ return
121
+ end
122
+
123
+ return if wait_before_retry(retry_delay(attempt))
124
+
125
+ attempt += 1
126
+ end
127
+ end
128
+
129
+ protected
130
+
131
+ def after_fork
132
+ # The child must report its own failures, while the inherited ETag still matches its configuration.
133
+ @warned = {}
134
+ nil
135
+ end
136
+
137
+ private
138
+
139
+ def stopped?
140
+ @lifecycle_mutex.synchronize { @stopped }
141
+ end
142
+
143
+ def retryable?(response)
144
+ status = response.status
145
+ return true unless status
146
+
147
+ retryable_status?(status)
148
+ end
149
+
150
+ def retryable_status?(status)
151
+ status == 408 || status == 429 || status.between?(500, 599)
152
+ end
153
+
154
+ def retry_delay(attempt)
155
+ base = if attempt == 1
156
+ clamp(@poll_interval_seconds / 6.0, FIRST_RETRY_MIN_SECONDS, FIRST_RETRY_MAX_SECONDS)
157
+ else
158
+ clamp(@poll_interval_seconds / 3.0, SECOND_RETRY_MIN_SECONDS, SECOND_RETRY_MAX_SECONDS)
159
+ end
160
+ random = @random.call
161
+ jitter = 1 - RETRY_JITTER + (random * RETRY_JITTER * 2)
162
+ [1.0, base * jitter].max
163
+ end
164
+
165
+ def clamp(value, minimum, maximum)
166
+ value.clamp(minimum, maximum)
167
+ end
168
+
169
+ def wait_before_retry(delay)
170
+ @retry_wait.call(delay)
171
+ end
172
+
173
+ def wait_for_retry(delay)
174
+ mutex.synchronize do
175
+ return true if stopped?
176
+
177
+ shutdown.wait(mutex, delay)
178
+ end
179
+
180
+ stopped? || !run_loop?
181
+ end
182
+
183
+ def apply_response(response)
184
+ case response.status
185
+ when 200
186
+ apply_configuration(response)
187
+ when 304
188
+ nil
189
+ when 401, 403
190
+ warn_once(:authentication) do
191
+ @logger.warn(
192
+ "Feature Flags agentless endpoint returned HTTP #{response.status}; verify endpoint authentication"
193
+ )
194
+ end
195
+ else
196
+ warn_failure(response, 1)
197
+ end
198
+ end
199
+
200
+ def apply_configuration(response)
201
+ configuration = response.body && Parser.parse(response.body)
202
+ unless configuration
203
+ warn_once(:malformed) do
204
+ @logger.error("Feature Flags agentless endpoint returned malformed UFC payload")
205
+ end
206
+ return
207
+ end
208
+
209
+ @apply.call(configuration)
210
+ etag = response.etag.to_s.strip
211
+ @etag = etag.empty? ? nil : etag
212
+ rescue EvaluationEngine::ReconfigurationError => error
213
+ warn_once(:application) do
214
+ @logger.warn("Feature Flags agentless UFC payload could not be applied: #{error.class}: #{error.message}")
215
+ end
216
+ end
217
+
218
+ def warn_failure(response, attempts)
219
+ status = response.status
220
+ category = if status.nil?
221
+ :request
222
+ elsif retryable_status?(status)
223
+ :http_retryable
224
+ else
225
+ :http_unexpected
226
+ end
227
+ warn_once(category) do
228
+ if status
229
+ @logger.warn("Feature Flags agentless endpoint returned HTTP #{status} after #{attempts} attempt(s)")
230
+ else
231
+ error_class = response.error ? response.error.class : StandardError
232
+ @logger.warn("Feature Flags agentless request failed after #{attempts} attempt(s): #{error_class}")
233
+ end
234
+ end
235
+ end
236
+
237
+ def warn_once(category)
238
+ return if @warned[category]
239
+
240
+ @warned[category] = true
241
+ yield
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Datadog
6
+ module OpenFeature
7
+ module Agentless
8
+ # Parses the JSON:API envelope returned by agentless delivery.
9
+ module Parser
10
+ RESOURCE_TYPE = "universal-flag-configuration"
11
+
12
+ module_function
13
+
14
+ def parse(body)
15
+ payload = JSON.parse(body)
16
+ return unless payload.is_a?(Hash)
17
+
18
+ data = payload["data"]
19
+ return unless data.is_a?(Hash) && data["type"] == RESOURCE_TYPE
20
+
21
+ attributes = data["attributes"]
22
+ return unless valid_attributes?(attributes)
23
+
24
+ JSON.generate(attributes)
25
+ rescue JSON::ParserError, JSON::GeneratorError, TypeError
26
+ nil
27
+ end
28
+
29
+ def valid_attributes?(attributes)
30
+ return false unless attributes.is_a?(Hash)
31
+
32
+ environment = attributes["environment"]
33
+ attributes["format"].is_a?(String) &&
34
+ attributes["createdAt"].is_a?(String) &&
35
+ environment.is_a?(Hash) &&
36
+ environment["name"].is_a?(String) &&
37
+ attributes["flags"].is_a?(Hash)
38
+ end
39
+ private_class_method :valid_attributes?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module OpenFeature
5
+ module Agentless
6
+ # Result of one agentless configuration request.
7
+ class Response
8
+ attr_reader :status, :etag, :body, :error
9
+
10
+ def initialize(status: nil, etag: nil, body: nil, error: nil)
11
+ @status = status
12
+ @etag = etag
13
+ @body = body
14
+ @error = error
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "stringio"
5
+ require "timeout"
6
+ require "uri"
7
+ require "zlib"
8
+
9
+ require_relative "../../core/environment/identity"
10
+ require_relative "../../core/transport/ext"
11
+ require_relative "response"
12
+
13
+ module Datadog
14
+ module OpenFeature
15
+ module Agentless
16
+ # HTTP transport for agentless configuration delivery.
17
+ class Transport
18
+ def initialize(endpoint:, api_key:, timeout_seconds:)
19
+ @endpoint = endpoint
20
+ @api_key = endpoint.managed? ? api_key : nil
21
+ @timeout_seconds = timeout_seconds
22
+ end
23
+
24
+ def get(etag)
25
+ request = Net::HTTP::Get.new(@endpoint.uri.request_uri, headers(etag))
26
+ apply_custom_authentication(request)
27
+ response = request(request)
28
+ status = response.code.to_i
29
+
30
+ Response.new(
31
+ status: status,
32
+ etag: response["ETag"],
33
+ # Decoding another status can erase the status the poller needs to classify the response.
34
+ body: (status == 200) ? response_body(response) : nil,
35
+ )
36
+ # Net::HTTP uses several version-specific exception types. None may end
37
+ # the delivery worker; the poller classifies every transport failure.
38
+ rescue => error
39
+ Response.new(error: error)
40
+ end
41
+
42
+ private
43
+
44
+ def apply_custom_authentication(request)
45
+ return if @endpoint.managed?
46
+
47
+ uri = @endpoint.uri
48
+ username = uri.user
49
+ return unless username
50
+
51
+ request.basic_auth(
52
+ URI::RFC2396_PARSER.unescape(username),
53
+ URI::RFC2396_PARSER.unescape(uri.password.to_s),
54
+ )
55
+ end
56
+
57
+ def headers(etag)
58
+ headers = {
59
+ "Accept-Encoding" => "gzip",
60
+ "DD-Client-Library-Language" => Core::Environment::Identity.lang,
61
+ "DD-Client-Library-Version" => Core::Environment::Identity.gem_datadog_version_semver2,
62
+ Core::Transport::Ext::HTTP::HEADER_DD_INTERNAL_UNTRACED_REQUEST => "1",
63
+ }
64
+ api_key = @api_key
65
+ headers[Core::Transport::Ext::HTTP::HEADER_DD_API_KEY] = api_key if api_key
66
+ headers["If-None-Match"] = etag if etag
67
+ headers
68
+ end
69
+
70
+ def request(request)
71
+ uri = @endpoint.uri
72
+ hostname = uri.hostname
73
+ raise ArgumentError, "Feature Flags agentless endpoint must have a host" unless hostname
74
+
75
+ # Agentless delivery requires public egress, so use Ruby's standard proxy discovery.
76
+ http = Net::HTTP.new(hostname, uri.port)
77
+ http.max_retries = 0
78
+ http.use_ssl = uri.scheme == "https"
79
+ http.open_timeout = @timeout_seconds
80
+ http.read_timeout = @timeout_seconds
81
+ http.write_timeout = @timeout_seconds if http.respond_to?(:write_timeout=)
82
+
83
+ # Bounds total request time, including DNS resolution, which the per-phase timeouts do not cover.
84
+ Timeout.timeout(@timeout_seconds) do
85
+ http.start { |connection| connection.request(request) }
86
+ end
87
+ end
88
+
89
+ def response_body(response)
90
+ body = response.body.to_s
91
+ return body unless response["Content-Encoding"].to_s.strip.casecmp("gzip") == 0
92
+
93
+ reader = Zlib::GzipReader.new(StringIO.new(body))
94
+ begin
95
+ reader.read.to_s
96
+ ensure
97
+ reader.close
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end