From 1eaaa3ca08439f363d7c161f70abf137fe4627d9 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 1 Jun 2026 11:58:43 -0400 Subject: [PATCH 01/42] DI: capture expressions Adds capture-expression support to Ruby log probes. * Parse the `captureExpressions` field in remote-config LOG_PROBE payloads. * Evaluate each expression against the probe scope at fire time. * Emit a `captureExpressions` block in the snapshot under the same `captures.{lines.|entry|return}` key that full snapshots use. * Per-fire time budget controlled by `DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS` (default 200). Remaining expressions emit a stub `{notCapturedReason: "timeout"}`. * Snapshot wins at fire time when both `captureSnapshot` and `captureExpressions` are set on the same probe (matches Python/Java/Go). * Per-expression evaluation errors are added to `evaluationErrors` with the failed expression's name; the failed key is omitted from the `captureExpressions` block. * Capture-expression probes default to the snapshot rate-limit bucket (1/sec); existing `sampling.snapshotsPerSecond` overrides this. Tests cover parsing (including the backend name pattern `^[a-zA-Z0-9_?]+$`), the rate-limit fallback, per-expression depth override, time-budget exhaustion, and the success/error/timeout output shapes. Known limitation: per-expression `maxLength` and `maxCollectionSize` are not yet honored end-to-end; the snapshot serializer reads those two limits from the DI settings directly. Per-expression `maxReferenceDepth` and `maxFieldCount` work. --- docs/DynamicInstrumentation.md | 49 +++++ .../configuration/supported_configurations.rb | 1 + lib/datadog/di/boot.rb | 2 + lib/datadog/di/capture_expression.rb | 78 ++++++++ .../di/capture_expression_evaluator.rb | 93 ++++++++++ lib/datadog/di/configuration.rb | 10 ++ lib/datadog/di/probe.rb | 37 +++- lib/datadog/di/probe_builder.rb | 57 ++++++ lib/datadog/di/probe_notification_builder.rb | 33 ++++ sig/datadog/di/capture_expression.rbs | 31 ++++ .../di/capture_expression_evaluator.rbs | 17 ++ sig/datadog/di/probe.rbs | 12 +- .../di/capture_expression_evaluator_spec.rb | 168 ++++++++++++++++++ spec/datadog/di/capture_expression_spec.rb | 106 +++++++++++ spec/datadog/di/probe_builder_spec.rb | 113 ++++++++++++ spec/datadog/di/probe_spec.rb | 60 +++++++ supported-configurations.json | 7 + 17 files changed, 871 insertions(+), 3 deletions(-) create mode 100644 lib/datadog/di/capture_expression.rb create mode 100644 lib/datadog/di/capture_expression_evaluator.rb create mode 100644 sig/datadog/di/capture_expression.rbs create mode 100644 sig/datadog/di/capture_expression_evaluator.rbs create mode 100644 spec/datadog/di/capture_expression_evaluator_spec.rb create mode 100644 spec/datadog/di/capture_expression_spec.rb diff --git a/docs/DynamicInstrumentation.md b/docs/DynamicInstrumentation.md index d170a19495..9a8ebfbead 100644 --- a/docs/DynamicInstrumentation.md +++ b/docs/DynamicInstrumentation.md @@ -146,6 +146,55 @@ supported for Ruby: - Span probes - Dynamic span tags +## Capture Expressions + +Log probes can carry a list of *capture expressions*: named DSL +expressions whose evaluated values are emitted in the snapshot under +the `captureExpressions` block. Capture expressions are an alternative +to `captureSnapshot: true` — they let you pick exactly which values to +capture instead of the whole local/argument scope. + +Each capture expression carries: + +- `name` — the key used in the snapshot output. Must match + `^[a-zA-Z0-9_?]+$`. +- `expr` — a DSL expression evaluated against the probe scope (uses + the same expression language as `when` conditions and message + templates). +- `capture` (optional) — per-expression + `maxReferenceDepth` / `maxCollectionSize` / `maxLength` / + `maxFieldCount` override. + +Behavior notes for Ruby: + +- **Snapshot vs. capture expressions are mutually exclusive at fire + time.** If a probe sets both `captureSnapshot: true` and a non-empty + `captureExpressions`, the full snapshot is emitted and the + capture-expression values are dropped. This matches the + Python/Java/Go behavior and supports graceful degradation for older + tracer versions that lack capture-expression support. +- **Per-field fallback.** Limits absent from a per-expression + `capture` block fall back to the probe-level `capture` and then to + the tracer's default settings, independently per field. +- **Rate-limit default.** A probe with `captureExpressions` set + defaults to the snapshot bucket of 1 invocation/second, not the + log-message bucket. Override with `sampling.snapshotsPerSecond` on + the probe. +- **Per-fire time budget.** Capture-expression evaluation for one + probe fire is bounded by `DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS` + (default `200`). When exceeded, remaining expressions are emitted + with `"notCapturedReason": "timeout"` so the truncation is visible + in the snapshot UI. +- **Evaluation errors are visible.** When an individual expression + fails to evaluate, its key is omitted from `captureExpressions` + and an `{ expr: , message: … }` entry is added to the + snapshot's `evaluationErrors` array. + +Known limitation: per-expression `maxLength` and `maxCollectionSize` +overrides are not yet honored end-to-end; the tracer falls back to the +DI settings defaults for these two fields. Per-expression +`maxReferenceDepth` and `maxFieldCount` work as documented. + ## Expression Language The Ruby tracer supports Dynamic Instrumentation expression language for diff --git a/lib/datadog/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index f8725aada2..3b735190c8 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -48,6 +48,7 @@ module Configuration "DD_DBM_INJECT_SQL_BASEHASH", "DD_DBM_PROPAGATION_MODE", "DD_DISABLE_DATADOG_RAILS", + "DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS", "DD_DYNAMIC_INSTRUMENTATION_ENABLED", "DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE", "DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS", diff --git a/lib/datadog/di/boot.rb b/lib/datadog/di/boot.rb index fba3309571..f8422f5de0 100644 --- a/lib/datadog/di/boot.rb +++ b/lib/datadog/di/boot.rb @@ -6,6 +6,8 @@ require_relative 'code_tracker' require_relative 'component' require_relative 'context' +require_relative 'capture_expression' +require_relative 'capture_expression_evaluator' require_relative 'instrumenter' require_relative 'probe' require_relative 'probe_builder' diff --git a/lib/datadog/di/capture_expression.rb b/lib/datadog/di/capture_expression.rb new file mode 100644 index 0000000000..94e69f9ea1 --- /dev/null +++ b/lib/datadog/di/capture_expression.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Datadog + module DI + # Configured capture expression on a log probe. + # + # Carries a user-supplied +name+ (used as the key in the snapshot + # output), a compiled +expr+ from the DI expression language, and + # optional per-expression +limits+ overriding the probe-level + # capture limits. + # + # Pure value object: every attribute round-trips through remote + # configuration and the snapshot payload; no in-process-only fields. + # + # @api private + class CaptureExpression + def initialize(name:, expr:, limits: nil) + @name = name + @expr = expr + @limits = limits + end + + attr_reader :name + attr_reader :expr + attr_reader :limits + end + + # Per-expression CaptureLimits override. + # + # Each field is independently optional. The +resolve+ class method + # walks a per-field fallback chain + # (expression -> probe -> language default) and returns a fully + # populated set of limits to pass to the serializer. + # + # @api private + class CaptureLimits + def initialize(max_reference_depth: nil, max_collection_size: nil, + max_length: nil, max_field_count: nil) + @max_reference_depth = max_reference_depth + @max_collection_size = max_collection_size + @max_length = max_length + @max_field_count = max_field_count + end + + attr_reader :max_reference_depth + attr_reader :max_collection_size + attr_reader :max_length + attr_reader :max_field_count + + # Resolves effective limits for a capture expression by walking + # the per-field fallback chain: + # + # expr_limits.X ?? probe.max_capture_X ?? settings.dynamic_instrumentation.max_capture_X + # + # Resolved independently per field — a missing field on the + # expression's limits falls through to the probe level, then to + # the language-wide default, without affecting the other fields. + # + # Returns a Hash with keys :depth, :collection_size, :length, + # :attribute_count to match the serializer's keyword arguments. + def self.resolve(expr_limits:, probe:, settings:) + di = settings.dynamic_instrumentation + { + depth: expr_limits&.max_reference_depth || + probe.max_capture_depth || + di.max_capture_depth, + collection_size: expr_limits&.max_collection_size || + di.max_capture_collection_size, + length: expr_limits&.max_length || + di.max_capture_string_length, + attribute_count: expr_limits&.max_field_count || + probe.max_capture_attribute_count || + di.max_capture_attribute_count, + } + end + end + end +end diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb new file mode 100644 index 0000000000..a0a7c66409 --- /dev/null +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require_relative "capture_expression" + +module Datadog + module DI + # Evaluates a probe's capture expressions against a captured context + # and serializes their results into a shape suitable for the + # `captureExpressions` block of a snapshot. + # + # On per-expression evaluation/serialization failure, the expression's + # key is omitted from the output and an `{ expr: name, message: ... }` + # entry is appended to the returned `evaluation_errors` array. This + # matches the cross-tracer convention (Python, Java, .NET, Node.js, + # Go DI) — see + # projects/capture-expressions/specs/wire-format.md §8.8 and + # projects/capture-expressions/design/decisions.md (D5). + # + # On per-fire time-budget exhaustion, remaining expressions emit a + # stub `{ "notCapturedReason" => "timeout" }` entry (Node.js shape; + # see D4). + # + # @api private + class CaptureExpressionEvaluator + # @param settings [Datadog::Core::Configuration::Settings] + # @param serializer [Datadog::DI::Serializer] + # @param telemetry [Datadog::Core::Telemetry::Component, nil] + def initialize(settings:, serializer:, telemetry: nil) + @settings = settings + @serializer = serializer + @telemetry = telemetry + end + + attr_reader :settings + attr_reader :serializer + attr_reader :telemetry + + # Evaluate +probe.capture_expressions+ against +context+. + # + # @param probe [Datadog::DI::Probe] + # @param context [Datadog::DI::Context] + # @return [Array(Hash, Array)] pair of + # - the `captureExpressions` block ({ name => serialized_value }) + # - the per-expression evaluation_errors array + def evaluate(probe, context) + budget_ns = settings.dynamic_instrumentation.capture_expression_timeout_ms * 1_000_000 + deadline_ns = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) + budget_ns + + output = {} + evaluation_errors = [] + + probe.capture_expressions.each do |capture_expression| + name = capture_expression.name + + if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns + output[name] = {notCapturedReason: "timeout"} + telemetry&.inc("dynamic_instrumentation.capture_expression_timeout", 1) + next + end + + begin + value = capture_expression.expr.evaluate(context) + limits = CaptureLimits.resolve( + expr_limits: capture_expression.limits, + probe: probe, + settings: settings, + ) + # NOTE: only depth and attribute_count are threaded through to + # the serializer here; max_length and max_collection_size are + # read from settings directly inside Serializer#serialize_value + # and are not yet overridable per expression. Fixing this + # requires adding length / collection_size kwargs to + # Serializer#serialize_value and its recursive call sites. + # Tracked as a follow-up in projects/capture-expressions/backlog. + output[name] = serializer.serialize_value( + value, name: name, + depth: limits[:depth], + attribute_count: limits[:attribute_count], + ) + rescue => exc + evaluation_errors << {expr: name, message: "#{exc.class}: #{exc.message}"} + Datadog.logger.debug do + "di: probe #{probe.id}: capture expression #{name}: evaluation failed: #{exc.class}: #{exc.message}" + end + telemetry&.report(exc, description: "DI capture-expression evaluation failed") + end + end + + [output, evaluation_errors] + end + end + end +end diff --git a/lib/datadog/di/configuration.rb b/lib/datadog/di/configuration.rb index 54a653b097..98ccffe240 100644 --- a/lib/datadog/di/configuration.rb +++ b/lib/datadog/di/configuration.rb @@ -134,6 +134,16 @@ def self.add_settings!(base) o.default 20 end + # Per-fire time budget for evaluating capture expressions, + # in milliseconds. Once exhausted, remaining capture + # expressions emit a stub entry with + # `notCapturedReason: "timeout"` in the snapshot. + option :capture_expression_timeout_ms do |o| + o.type :int + o.default 200 + o.env 'DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS' + end + # Settings in the 'internal' group are for internal Datadog # use only, and are needed to test dynamic instrumentation or # experiment with features not released to customers. diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index 85d5ddf6df..ec2926b96d 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -38,7 +38,10 @@ def initialize(id:, type:, file: nil, line_no: nil, type_name: nil, method_name: nil, template: nil, template_segments: nil, capture_snapshot: false, max_capture_depth: nil, - max_capture_attribute_count: nil, condition: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: nil, max_capture_string_length: nil, + capture_expressions: [], + condition: nil, rate_limit: nil) # Perform some sanity checks here to detect unexpected attribute # combinations, in order to not do them in subsequent code. @@ -81,9 +84,18 @@ def initialize(id:, type:, @capture_snapshot = !!capture_snapshot @max_capture_depth = max_capture_depth @max_capture_attribute_count = max_capture_attribute_count + @max_capture_collection_size = max_capture_collection_size + @max_capture_string_length = max_capture_string_length + @capture_expressions = capture_expressions || [] @condition = condition - @rate_limit = rate_limit || (@capture_snapshot ? 1 : 5000) + # Capture-expression probes are charged against the snapshot rate-limit + # bucket (1/sec default), matching Python/.NET/Go DI. They are not + # treated as cheap log probes (5000/sec) because evaluating arbitrary + # user-authored expressions has snapshot-class cost. + # See projects/capture-expressions/design/decisions.md (D3) in the + # planning repo for the cross-tracer comparison. + @rate_limit = rate_limit || ((@capture_snapshot || !@capture_expressions.empty?) ? 1 : 5000) @rate_limiter = Datadog::Core::TokenBucket.new(@rate_limit) # At most one report per second. @@ -120,6 +132,22 @@ def initialize(id:, type:, # the global default will be used. attr_reader :max_capture_attribute_count + # Configured maximum collection size. Can be nil in which case the + # global default will be used. Currently only used by capture + # expressions; the snapshot serializer reads the setting directly. + attr_reader :max_capture_collection_size + + # Configured maximum string length. Can be nil in which case the + # global default will be used. Currently only used by capture + # expressions; the snapshot serializer reads the setting directly. + attr_reader :max_capture_string_length + + # Capture expressions attached to this probe. Empty array when no + # capture expressions are configured. + # + # @return [Array] + attr_reader :capture_expressions + # Rate limit in effect, in invocations per second. Always present. attr_reader :rate_limit @@ -140,6 +168,11 @@ def capture_snapshot? @capture_snapshot end + # Returns whether the probe has any capture expressions configured. + def capture_expressions? + !@capture_expressions.empty? + end + # Returns whether the probe is a line probe. # # Method probes may still specify a file name (to aid in locating the diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index e37a6fc409..ae6eb0053e 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -3,6 +3,7 @@ # rubocop:disable Lint/AssignmentInCondition require_relative "probe" +require_relative "capture_expression" require_relative 'el' module Datadog @@ -27,6 +28,11 @@ module ProbeBuilder module_function + # Backend JSON schema constraint on capture-expression names: + # remote-config/apps/rc-schema-validation/schemas/live-debugging.json + # in DataDog/dd-go declares pattern "^[a-zA-Z0-9_?]+$". + CAPTURE_EXPRESSION_NAME_PATTERN = /\A[a-zA-Z0-9_?]+\z/ + def build_from_remote_config(config) # The validations here are not yet comprehensive. type = config.fetch('type') @@ -38,6 +44,17 @@ def build_from_remote_config(config) compiled = EL::Compiler.new.compile(cond_spec['json']) EL::Expression.new(cond_spec['dsl'], compiled) end + capture_expressions = build_capture_expressions(config['captureExpressions']) + if !!config["captureSnapshot"] && !capture_expressions.empty? + # Per the project design, the runtime emits the snapshot block and + # silently drops capture-expression values when both are set + # (snapshot wins). Logged at debug to make the choice observable + # without spamming operator logs. See + # projects/capture-expressions/design/decisions.md (D1). + Datadog.logger.debug do + "di: probe #{config["id"]}: captureSnapshot=true wins over captureExpressions (n=#{capture_expressions.size})" + end + end Probe.new( id: config.fetch("id"), type: type_symbol, @@ -54,6 +71,9 @@ def build_from_remote_config(config) capture_snapshot: !!config["captureSnapshot"], max_capture_depth: config["capture"]&.[]("maxReferenceDepth"), max_capture_attribute_count: config["capture"]&.[]("maxFieldCount"), + max_capture_collection_size: config["capture"]&.[]("maxCollectionSize"), + max_capture_string_length: config["capture"]&.[]("maxLength"), + capture_expressions: capture_expressions, rate_limit: config["sampling"]&.[]("snapshotsPerSecond"), condition: cond, ) @@ -61,6 +81,43 @@ def build_from_remote_config(config) raise ArgumentError, "Malformed remote configuration entry for probe: #{exc.class}: #{exc.message}: #{config}" end + def build_capture_expressions(raw) + return [] if raw.nil? || raw.empty? + unless Array === raw + raise ArgumentError, "captureExpressions must be an array, got: #{raw.class}" + end + raw.map do |entry| + unless Hash === entry + raise ArgumentError, "captureExpressions entry must be a hash, got: #{entry.class}" + end + name = entry['name'] + unless String === name && CAPTURE_EXPRESSION_NAME_PATTERN.match?(name) + raise ArgumentError, "captureExpressions entry name missing or invalid (must match #{CAPTURE_EXPRESSION_NAME_PATTERN.inspect}): #{name.inspect}" + end + expr_spec = entry['expr'] + unless Hash === expr_spec && expr_spec['dsl'] && expr_spec['json'] + raise ArgumentError, "captureExpressions entry #{name}: missing or malformed expr" + end + compiled = EL::Compiler.new.compile(expr_spec['json']) + expr = EL::Expression.new(expr_spec['dsl'], compiled) + limits = build_capture_limits(entry['capture']) + CaptureExpression.new(name: name, expr: expr, limits: limits) + end + end + + def build_capture_limits(raw) + return nil if raw.nil? + unless Hash === raw + raise ArgumentError, "capture-expression entry capture must be a hash, got: #{raw.class}" + end + CaptureLimits.new( + max_reference_depth: raw['maxReferenceDepth'], + max_collection_size: raw['maxCollectionSize'], + max_length: raw['maxLength'], + max_field_count: raw['maxFieldCount'], + ) + end + def build_template_segments(segments) segments&.map do |segment| if Hash === segment diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index acea84f660..9c5a4d16b9 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -3,6 +3,7 @@ # rubocop:disable Lint/AssignmentInCondition require_relative 'fatal_exceptions' +require_relative 'capture_expression_evaluator' module Datadog module DI @@ -13,10 +14,14 @@ class ProbeNotificationBuilder def initialize(settings, serializer) @settings = settings @serializer = serializer + @capture_expression_evaluator = CaptureExpressionEvaluator.new( + settings: settings, serializer: serializer, + ) end attr_reader :settings attr_reader :serializer + attr_reader :capture_expression_evaluator def build_received(probe) build_status(probe, @@ -65,8 +70,14 @@ def build_snapshot(context) raise ArgumentError, "Asked to build snapshot with snapshot capture but target_self is nil" end + # Mutual exclusion (D1): capture_snapshot wins at fire time when both + # captureSnapshot and captureExpressions are set on the same probe. + # The capture-expression values are silently dropped in that case; + # the user-visible mutual exclusion is also logged at parse time in + # ProbeBuilder. See projects/capture-expressions/design/decisions.md. # TODO also verify that non-capturing probe does not pass # snapshot or vars/args into this method + capture_expression_evaluation_errors = [] captures = if probe.capture_snapshot? if probe.method? return_arguments = { @@ -94,6 +105,24 @@ def build_snapshot(context) }, } end + elsif probe.capture_expressions? + captured_block, capture_expression_evaluation_errors = + capture_expression_evaluator.evaluate(probe, context) + if probe.method? + { + entry: {captureExpressions: captured_block}, + return: { + captureExpressions: captured_block, + throwable: context.exception ? serialize_throwable(context.exception) : nil, + }, + } + elsif probe.line? + { + lines: { + probe.line_no => {captureExpressions: captured_block}, + }, + } + end end message = nil @@ -101,6 +130,10 @@ def build_snapshot(context) if segments = probe.template_segments message, evaluation_errors = evaluate_template(segments, context) end + # Per-expression evaluation errors are merged into the snapshot's + # top-level evaluationErrors array alongside template/condition errors. + # See projects/capture-expressions/design/decisions.md (D5). + evaluation_errors.concat(capture_expression_evaluation_errors) build_snapshot_base(context, evaluation_errors: evaluation_errors, message: message, captures: captures) diff --git a/sig/datadog/di/capture_expression.rbs b/sig/datadog/di/capture_expression.rbs new file mode 100644 index 0000000000..c3622c6435 --- /dev/null +++ b/sig/datadog/di/capture_expression.rbs @@ -0,0 +1,31 @@ +module Datadog + module DI + class CaptureExpression + @name: String + @expr: DI::EL::Expression + @limits: CaptureLimits? + + def initialize: (name: String, expr: DI::EL::Expression, ?limits: CaptureLimits?) -> void + + attr_reader name: String + attr_reader expr: DI::EL::Expression + attr_reader limits: CaptureLimits? + end + + class CaptureLimits + @max_reference_depth: Integer? + @max_collection_size: Integer? + @max_length: Integer? + @max_field_count: Integer? + + def initialize: (?max_reference_depth: Integer?, ?max_collection_size: Integer?, ?max_length: Integer?, ?max_field_count: Integer?) -> void + + attr_reader max_reference_depth: Integer? + attr_reader max_collection_size: Integer? + attr_reader max_length: Integer? + attr_reader max_field_count: Integer? + + def self.resolve: (expr_limits: CaptureLimits?, probe: Probe, settings: untyped) -> Hash[Symbol, Integer] + end + end +end diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs new file mode 100644 index 0000000000..596f9efddc --- /dev/null +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -0,0 +1,17 @@ +module Datadog + module DI + class CaptureExpressionEvaluator + @settings: untyped + @serializer: Datadog::DI::Serializer + @telemetry: untyped? + + def initialize: (settings: untyped, serializer: Datadog::DI::Serializer, ?telemetry: untyped?) -> void + + attr_reader settings: untyped + attr_reader serializer: Datadog::DI::Serializer + attr_reader telemetry: untyped? + + def evaluate: (Probe probe, Context context) -> [Hash[String, untyped], Array[Hash[Symbol, String]]] + end + end +end diff --git a/sig/datadog/di/probe.rbs b/sig/datadog/di/probe.rbs index 2784c0b26a..7195dc3a0f 100644 --- a/sig/datadog/di/probe.rbs +++ b/sig/datadog/di/probe.rbs @@ -32,7 +32,10 @@ module Datadog def initialize: (id: String, type: Symbol, ?file: String?, ?line_no: Integer?, ?type_name: String?, ?method_name: String?, ?template: String?, ?template_segments: Array[String | DI::EL::Expression]?, ?capture_snapshot: bool, ?condition: DI::EL::Expression?, - ?max_capture_depth: Integer?, ?max_capture_attribute_count: Integer?, ?rate_limit: Integer?) -> void + ?max_capture_depth: Integer?, ?max_capture_attribute_count: Integer?, + ?max_capture_collection_size: Integer?, ?max_capture_string_length: Integer?, + ?capture_expressions: Array[CaptureExpression], + ?rate_limit: Integer?) -> void attr_reader condition: DI::EL::Expression? @@ -52,6 +55,12 @@ module Datadog attr_reader max_capture_attribute_count: Integer? + attr_reader max_capture_collection_size: Integer? + + attr_reader max_capture_string_length: Integer? + + attr_reader capture_expressions: Array[CaptureExpression] + attr_reader template: String? attr_reader template_segments: Array[String | DI::EL::Expression]? attr_reader rate_limiter: Datadog::Core::RateLimiter @@ -59,6 +68,7 @@ module Datadog attr_reader condition_evaluation_failed_rate_limiter: Datadog::Core::RateLimiter? def capture_snapshot?: () -> bool + def capture_expressions?: () -> bool def line?: () -> bool def method?: () -> bool def line_no!: () -> Integer diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb new file mode 100644 index 0000000000..769345c567 --- /dev/null +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -0,0 +1,168 @@ +require "datadog/di/spec_helper" +require "datadog/di/capture_expression" +require "datadog/di/capture_expression_evaluator" +require "datadog/di/probe" +require "datadog/di/serializer" +require "datadog/di/redactor" +require "datadog/di/el" +require_relative "serializer_helper" + +RSpec.describe Datadog::DI::CaptureExpressionEvaluator do + di_test + + extend SerializerHelper + + default_settings + + before do + allow(di_settings).to receive(:capture_expression_timeout_ms).and_return(200) + end + + let(:redactor) do + Datadog::DI::Redactor.new(settings) + end + + let(:serializer) do + Datadog::DI::Serializer.new(settings, redactor) + end + + let(:evaluator) do + described_class.new(settings: settings, serializer: serializer) + end + + def compile_expression(dsl_string, json) + compiled = Datadog::DI::EL::Compiler.new.compile(json) + Datadog::DI::EL::Expression.new(dsl_string, compiled) + end + + let(:context) do + Datadog::DI::Context.new( + probe: probe, + settings: settings, + serializer: serializer, + locals: {x: 42, name: "alice"}, + target_self: nil, + ) + end + + describe "#evaluate" do + context "with a single successful expression" do + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "x", expr: compile_expression("x", {"ref" => "x"}), + ), + ], + ) + end + + it "emits the serialized value under the name" do + output, errors = evaluator.evaluate(probe, context) + expect(output.keys).to eq(["x"]) + expect(output["x"]).to include(type: "Integer", value: "42") + expect(errors).to eq([]) + end + end + + context "expression evaluation raises" do + # len() of a non-Array/String/Hash raises ExpressionEvaluationError. + # The locals do not have :badvar, so the ref resolves to nil, and len(nil) raises. + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "bad_len", + expr: compile_expression("len(badvar)", + {"len" => {"ref" => "badvar"}}), + ), + ], + ) + end + + it "omits the key from output and appends to evaluation_errors" do + output, errors = evaluator.evaluate(probe, context) + expect(output).to eq({}) + expect(errors.size).to eq(1) + expect(errors.first[:expr]).to eq("bad_len") + expect(errors.first[:message]).to include("ExpressionEvaluationError") + end + end + + context "mixed success and failure" do + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "ok", expr: compile_expression("x", {"ref" => "x"}), + ), + Datadog::DI::CaptureExpression.new( + name: "fail", + expr: compile_expression("len(badvar)", + {"len" => {"ref" => "badvar"}}), + ), + ], + ) + end + + it "succeeds for the good one, errors for the bad one" do + output, errors = evaluator.evaluate(probe, context) + expect(output.keys).to eq(["ok"]) + expect(errors.size).to eq(1) + expect(errors.first[:expr]).to eq("fail") + end + end + + context "time budget exhausted before evaluating any" do + before do + allow(di_settings).to receive(:capture_expression_timeout_ms).and_return(0) + end + + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "x", expr: compile_expression("x", {"ref" => "x"}), + ), + Datadog::DI::CaptureExpression.new( + name: "y", expr: compile_expression("name", {"ref" => "name"}), + ), + ], + ) + end + + it "emits notCapturedReason:timeout stubs for all expressions" do + output, errors = evaluator.evaluate(probe, context) + expect(output["x"]).to eq(notCapturedReason: "timeout") + expect(output["y"]).to eq(notCapturedReason: "timeout") + expect(errors).to eq([]) + end + end + + context "per-expression depth limit overrides probe-level depth" do + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + max_capture_depth: 1, + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "deep", expr: compile_expression("x", {"ref" => "x"}), + limits: Datadog::DI::CaptureLimits.new(max_reference_depth: 5), + ), + ], + ) + end + + # The depth value passed to the serializer is what we are asserting on + # rather than the resulting JSON shape. + it "passes the expression's depth into the serializer" do + expect(serializer).to receive(:serialize_value).with(42, hash_including(depth: 5, attribute_count: 10)).and_call_original + evaluator.evaluate(probe, context) + end + end + end +end diff --git a/spec/datadog/di/capture_expression_spec.rb b/spec/datadog/di/capture_expression_spec.rb new file mode 100644 index 0000000000..9d56a79f7a --- /dev/null +++ b/spec/datadog/di/capture_expression_spec.rb @@ -0,0 +1,106 @@ +require "datadog/di/spec_helper" +require "datadog/di/capture_expression" + +RSpec.describe Datadog::DI::CaptureExpression do + di_test + + describe ".new" do + it "stores name, expr and limits" do + ce = described_class.new(name: "x", expr: :compiled, limits: :limits) + expect(ce.name).to eq("x") + expect(ce.expr).to eq(:compiled) + expect(ce.limits).to eq(:limits) + end + + it "defaults limits to nil" do + ce = described_class.new(name: "x", expr: :compiled) + expect(ce.limits).to be_nil + end + end +end + +RSpec.describe Datadog::DI::CaptureLimits do + di_test + + describe ".new" do + it "stores all four fields" do + cl = described_class.new( + max_reference_depth: 5, + max_collection_size: 50, + max_length: 100, + max_field_count: 10, + ) + expect(cl.max_reference_depth).to eq(5) + expect(cl.max_collection_size).to eq(50) + expect(cl.max_length).to eq(100) + expect(cl.max_field_count).to eq(10) + end + + it "defaults all four fields to nil" do + cl = described_class.new + expect(cl.max_reference_depth).to be_nil + expect(cl.max_collection_size).to be_nil + expect(cl.max_length).to be_nil + expect(cl.max_field_count).to be_nil + end + end + + describe ".resolve" do + let(:settings) do + double("settings", dynamic_instrumentation: double( + max_capture_depth: 3, + max_capture_collection_size: 100, + max_capture_string_length: 255, + max_capture_attribute_count: 20, + )) + end + let(:probe) do + double("probe", + max_capture_depth: nil, + max_capture_attribute_count: nil,) + end + + context "no expression limits and no probe limits" do + it "falls back to settings for all four fields" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 100, length: 255, attribute_count: 20) + end + end + + context "probe-level limits set" do + let(:probe) do + double("probe", max_capture_depth: 7, max_capture_attribute_count: 99) + end + + it "uses probe limits for depth and attribute_count, settings for the rest" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 7, collection_size: 100, length: 255, attribute_count: 99) + end + end + + context "per-expression limits set on a subset of fields" do + let(:expr_limits) do + described_class.new(max_length: 50) + end + + it "uses expression limit for the set field; probe / settings for the rest" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 100, length: 50, attribute_count: 20) + end + end + + context "per-expression limits and probe limits both set" do + let(:expr_limits) do + described_class.new(max_reference_depth: 8) + end + let(:probe) do + double("probe", max_capture_depth: 5, max_capture_attribute_count: 7) + end + + it "expression wins over probe; probe wins over settings for the rest" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 8, collection_size: 100, length: 255, attribute_count: 7) + end + end + end +end diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index ee95dc99b7..dbdf929e85 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -175,5 +175,118 @@ end.to raise_error(ArgumentError, /Malformed remote configuration entry/) end end + + context "capture expressions" do + let(:base_spec) do + { + "id" => "ce-test", + "type" => "LOG_PROBE", + "where" => {"typeName" => "Foo", "methodName" => "bar"}, + "template" => "", + "segments" => [], + "captureSnapshot" => false, + } + end + + let(:valid_capture_expression) do + { + "name" => "x", + "expr" => {"dsl" => "x", "json" => {"ref" => "x"}}, + } + end + + context "with a valid captureExpressions entry" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [valid_capture_expression]) + end + + it "creates a probe with capture expressions" do + expect(probe.capture_expressions.size).to eq(1) + expect(probe.capture_expressions.first.name).to eq("x") + expect(probe.capture_expressions.first.expr).to be_a(Datadog::DI::EL::Expression) + expect(probe.capture_expressions.first.limits).to be_nil + end + + it "defaults to snapshot-class rate limit (1/sec)" do + expect(probe.rate_limit).to eq(1) + end + end + + context "with per-expression capture limits" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [ + valid_capture_expression.merge("capture" => { + "maxReferenceDepth" => 7, + "maxCollectionSize" => 17, + "maxLength" => 50, + "maxFieldCount" => 11, + }), + ]) + end + + it "populates CaptureLimits with the per-expression values" do + limits = probe.capture_expressions.first.limits + expect(limits).to be_a(Datadog::DI::CaptureLimits) + expect(limits.max_reference_depth).to eq(7) + expect(limits.max_collection_size).to eq(17) + expect(limits.max_length).to eq(50) + expect(limits.max_field_count).to eq(11) + end + end + + context "name violates the backend pattern" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [ + valid_capture_expression.merge("name" => "invalid-name-with-hyphens"), + ]) + end + + it "raises ArgumentError" do + expect { probe }.to raise_error(ArgumentError, /name missing or invalid/) + end + end + + context "missing expr" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [{"name" => "x"}]) + end + + it "raises ArgumentError" do + expect { probe }.to raise_error(ArgumentError, /missing or malformed expr/) + end + end + + context "empty captureExpressions array" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => []) + end + + it "creates a probe with no capture expressions" do + expect(probe.capture_expressions).to eq([]) + expect(probe.capture_expressions?).to be false + end + end + + context "captureSnapshot=true and non-empty captureExpressions (mutual exclusion at fire time)" do + let(:rc_probe_spec) do + base_spec.merge( + "captureSnapshot" => true, + "captureExpressions" => [valid_capture_expression], + ) + end + + it "creates the probe successfully (mutual exclusion resolved at fire time)" do + expect(probe.capture_snapshot?).to be true + expect(probe.capture_expressions?).to be true + end + + it "logs a debug message about snapshot winning" do + expect(Datadog.logger).to receive(:debug) do |&block| + expect(block.call).to match(/captureSnapshot=true wins over captureExpressions/) + end + probe + end + end + end end end diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 88403759bf..b40c14daf7 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -198,6 +198,66 @@ end end + describe "capture expressions" do + let(:capture_expression) do + Datadog::DI::CaptureExpression.new(name: "x", expr: nil) + end + + context "no capture_expressions argument" do + include_context "method probe" + + it "defaults to an empty array and capture_expressions? is false" do + expect(probe.capture_expressions).to eq([]) + expect(probe.capture_expressions?).to be false + end + end + + context "capture_expressions provided" do + let(:probe) do + described_class.new( + id: "42", type: :log, type_name: "Foo", method_name: "bar", + capture_expressions: [capture_expression], + ) + end + + it "stores them and capture_expressions? is true" do + expect(probe.capture_expressions).to eq([capture_expression]) + expect(probe.capture_expressions?).to be true + end + + it "defaults rate_limit to 1/sec (snapshot-class) when only capture_expressions set" do + expect(probe.rate_limit).to eq(1) + end + end + + context "capture_expressions explicitly empty array, capture_snapshot false" do + let(:probe) do + described_class.new( + id: "42", type: :log, type_name: "Foo", method_name: "bar", + capture_expressions: [], + ) + end + + it "defaults rate_limit to 5000/sec (log-class)" do + expect(probe.rate_limit).to eq(5000) + end + end + + context "explicit rate_limit overrides the capture-expressions default" do + let(:probe) do + described_class.new( + id: "42", type: :log, type_name: "Foo", method_name: "bar", + capture_expressions: [capture_expression], + rate_limit: 42, + ) + end + + it "uses the explicit value" do + expect(probe.rate_limit).to eq(42) + end + end + end + describe "#location" do context "method probe" do include_context "method probe" diff --git a/supported-configurations.json b/supported-configurations.json index fd89aa8abe..7f0ef44d13 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -280,6 +280,13 @@ "default": null } ], + "DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS": [ + { + "version": "A", + "type": "int", + "default": "200" + } + ], "DD_DYNAMIC_INSTRUMENTATION_ENABLED": [ { "version": "A", From e654ddc26761317fdf3fbb29ab08aa1d3ab73306 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 1 Jun 2026 13:14:12 -0400 Subject: [PATCH 02/42] DI: capture expressions: full per-expression limit support + Steep sigs - Thread max_length and max_collection_size kwargs through Serializer#serialize_value and its recursive calls, so per-expression CaptureLimits overrides for all four fields work end-to-end (previously only depth and attribute_count were honored). - Add Steep signatures for the new ProbeBuilder helpers, the capture_expression_evaluator accessor on ProbeNotificationBuilder, and the new length/collection_size kwargs on Serializer#serialize_value. - Remove the 'known limitation' note from docs/DynamicInstrumentation.md. --- docs/DynamicInstrumentation.md | 5 ---- .../di/capture_expression_evaluator.rb | 9 ++----- lib/datadog/di/serializer.rb | 27 ++++++++++++++----- sig/datadog/di/probe_builder.rbs | 6 +++++ sig/datadog/di/probe_notification_builder.rbs | 2 ++ sig/datadog/di/serializer.rbs | 2 +- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/docs/DynamicInstrumentation.md b/docs/DynamicInstrumentation.md index 9a8ebfbead..1c6e3c5e80 100644 --- a/docs/DynamicInstrumentation.md +++ b/docs/DynamicInstrumentation.md @@ -190,11 +190,6 @@ Behavior notes for Ruby: and an `{ expr: , message: … }` entry is added to the snapshot's `evaluationErrors` array. -Known limitation: per-expression `maxLength` and `maxCollectionSize` -overrides are not yet honored end-to-end; the tracer falls back to the -DI settings defaults for these two fields. Per-expression -`maxReferenceDepth` and `maxFieldCount` work as documented. - ## Expression Language The Ruby tracer supports Dynamic Instrumentation expression language for diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index a0a7c66409..1f09be9445 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -65,17 +65,12 @@ def evaluate(probe, context) probe: probe, settings: settings, ) - # NOTE: only depth and attribute_count are threaded through to - # the serializer here; max_length and max_collection_size are - # read from settings directly inside Serializer#serialize_value - # and are not yet overridable per expression. Fixing this - # requires adding length / collection_size kwargs to - # Serializer#serialize_value and its recursive call sites. - # Tracked as a follow-up in projects/capture-expressions/backlog. output[name] = serializer.serialize_value( value, name: name, depth: limits[:depth], attribute_count: limits[:attribute_count], + length: limits[:length], + collection_size: limits[:collection_size], ) rescue => exc evaluation_errors << {expr: name, message: "#{exc.class}: #{exc.message}"} diff --git a/lib/datadog/di/serializer.rb b/lib/datadog/di/serializer.rb index a46251d039..2472d3e9e5 100644 --- a/lib/datadog/di/serializer.rb +++ b/lib/datadog/di/serializer.rb @@ -160,9 +160,16 @@ def serialize_vars(vars, # (integers, strings, arrays, hashes). # # Respects string length, collection size and traversal depth limits. + # + # +length+ and +collection_size+ accept nil to mean "use the global + # default setting". They are threaded through to recursive calls so + # callers (e.g. the capture-expression evaluator) can override them + # per evaluation without re-routing through the global settings. def serialize_value(value, name: nil, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: nil, + length: nil, + collection_size: nil, type: nil) attribute_count ||= settings.dynamic_instrumentation.max_capture_attribute_count cls = type || value.class @@ -198,6 +205,10 @@ def serialize_value(value, name: nil, if condition_result serializer_proc = entry.fetch(:proc) + # Custom serializers do not yet accept length / collection_size; + # they receive the standard depth only. This matches the + # pre-capture-expressions behavior so existing custom + # serializers continue to work. return serializer_proc.call(self, value, name: nil, depth: depth) end end @@ -235,7 +246,8 @@ def serialize_value(value, name: nil, # # Truncate binary data BEFORE escaping to avoid cutting mid-escape-sequence. # For regular strings, the limit is applied to string length in characters. - max = settings.dynamic_instrumentation.max_capture_string_length + length ||= settings.dynamic_instrumentation.max_capture_string_length + max = length if value.encoding == Encoding::BINARY || !value.valid_encoding? # Truncate binary data BEFORE escaping to avoid cutting mid-escape-sequence @@ -263,7 +275,8 @@ def serialize_value(value, name: nil, if depth <= 0 serialized.update(notCapturedReason: "depth") else - max = settings.dynamic_instrumentation.max_capture_collection_size + collection_size ||= settings.dynamic_instrumentation.max_capture_collection_size + max = collection_size if max != 0 && value.length > max serialized.update(notCapturedReason: "collectionSize", size: value.length) # same steep failure with array slices. @@ -271,7 +284,7 @@ def serialize_value(value, name: nil, value = value[0...max] || [] end entries = value.map do |elt| - serialize_value(elt, depth: depth - 1) + serialize_value(elt, depth: depth - 1, length: length, collection_size: collection_size, attribute_count: attribute_count) end serialized.update(elements: entries) end @@ -279,7 +292,8 @@ def serialize_value(value, name: nil, if depth <= 0 serialized.update(notCapturedReason: "depth") else - max = settings.dynamic_instrumentation.max_capture_collection_size + collection_size ||= settings.dynamic_instrumentation.max_capture_collection_size + max = collection_size cur = 0 entries = [] value.each do |k, v| @@ -288,7 +302,8 @@ def serialize_value(value, name: nil, break end cur += 1 - entries << [serialize_value(k, depth: depth - 1), serialize_value(v, name: k, depth: depth - 1)] + entries << [serialize_value(k, depth: depth - 1, length: length, collection_size: collection_size, attribute_count: attribute_count), + serialize_value(v, name: k, depth: depth - 1, length: length, collection_size: collection_size, attribute_count: attribute_count)] end serialized.update(entries: entries) end @@ -327,7 +342,7 @@ def serialize_value(value, name: nil, break end cur += 1 - fields[ivar] = serialize_value(value.instance_variable_get(ivar), name: ivar, depth: depth - 1) + fields[ivar] = serialize_value(value.instance_variable_get(ivar), name: ivar, depth: depth - 1, length: length, collection_size: collection_size, attribute_count: attribute_count) end serialized.update(fields: fields) end diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index 4bbfdb7ac1..a3aa44fd94 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -3,9 +3,15 @@ module Datadog module ProbeBuilder PROBE_TYPES: { "LOG_PROBE" => :log } + CAPTURE_EXPRESSION_NAME_PATTERN: Regexp + def self?.build_from_remote_config: (Hash[String, untyped] config) -> Probe def self?.build_template_segments: (Array[Hash[String, untyped]]? segments) -> Array[String | DI::EL::Expression]? + + def self?.build_capture_expressions: (Array[Hash[String, untyped]]? raw) -> Array[CaptureExpression] + + def self?.build_capture_limits: (Hash[String, untyped]? raw) -> CaptureLimits? end end end diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index 006d5b013b..e0ebfae960 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -7,11 +7,13 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer + @capture_expression_evaluator: CaptureExpressionEvaluator def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Serializer + attr_reader capture_expression_evaluator: CaptureExpressionEvaluator def build_received: (Probe probe) -> Hash[Symbol,untyped] diff --git a/sig/datadog/di/serializer.rbs b/sig/datadog/di/serializer.rbs index 2d2a892aa9..1b6d3171f7 100644 --- a/sig/datadog/di/serializer.rbs +++ b/sig/datadog/di/serializer.rbs @@ -24,7 +24,7 @@ module Datadog def combine_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped target_self) -> Hash[Symbol, untyped] def serialize_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped instance_vars, ?depth: Integer, ?attribute_count: Integer?) -> Hash[Symbol, untyped] def serialize_vars: (Hash[Symbol, untyped] vars, ?depth: Integer, ?attribute_count: Integer?) -> Hash[Symbol, untyped] - def serialize_value: (untyped value, ?name: (Symbol | String)?, ?depth: Integer, ?attribute_count: Integer?, ?type: Class?) -> Hash[Symbol, untyped] + def serialize_value: (untyped value, ?name: (Symbol | String)?, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?, ?type: Class?) -> Hash[Symbol, untyped] def serialize_value_for_message: (untyped value, ?::Integer depth) -> String? def self.register: (?condition: Proc?) { From c6b5c3c598a13bcb1b8bc399ae671c68a7ff2b63 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 1 Jun 2026 15:57:28 -0400 Subject: [PATCH 03/42] DI: capture expressions: reuse DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE The prior commit added DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS as a new key, which would require central Configuration Registry coordination and broke validate_supported_configurations_v2_local_file. Switch to the existing DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE env var that .NET already declares (and which is already centrally registered). Its 200 ms default matches our chosen budget; its semantics extend naturally to whole-snapshot serialization if Ruby later applies the budget more broadly. * Rename the setting from capture_expression_timeout_ms to max_time_to_serialize_ms. * Drop the new DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS entry from supported-configurations.json and supported_configurations.rb; add the existing DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE. * Update docs/DynamicInstrumentation.md to reference the .NET-aligned env var. * Update the capture-expression evaluator and spec to use the new setting name. --- docs/DynamicInstrumentation.md | 9 +++++---- .../core/configuration/supported_configurations.rb | 2 +- lib/datadog/di/capture_expression_evaluator.rb | 2 +- lib/datadog/di/configuration.rb | 14 ++++++++++---- .../di/capture_expression_evaluator_spec.rb | 4 ++-- supported-configurations.json | 12 ++++++------ 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/DynamicInstrumentation.md b/docs/DynamicInstrumentation.md index 1c6e3c5e80..0cc7038063 100644 --- a/docs/DynamicInstrumentation.md +++ b/docs/DynamicInstrumentation.md @@ -181,10 +181,11 @@ Behavior notes for Ruby: log-message bucket. Override with `sampling.snapshotsPerSecond` on the probe. - **Per-fire time budget.** Capture-expression evaluation for one - probe fire is bounded by `DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS` - (default `200`). When exceeded, remaining expressions are emitted - with `"notCapturedReason": "timeout"` so the truncation is visible - in the snapshot UI. + probe fire is bounded by `DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE` + (default `200` ms). When exceeded, remaining expressions are + emitted with `"notCapturedReason": "timeout"` so the truncation is + visible in the snapshot UI. This env var is shared with the .NET + tracer's whole-snapshot serialization budget. - **Evaluation errors are visible.** When an individual expression fails to evaluate, its key is omitted from `captureExpressions` and an `{ expr: , message: … }` entry is added to the diff --git a/lib/datadog/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index 3b735190c8..761106c39e 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -48,8 +48,8 @@ module Configuration "DD_DBM_INJECT_SQL_BASEHASH", "DD_DBM_PROPAGATION_MODE", "DD_DISABLE_DATADOG_RAILS", - "DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS", "DD_DYNAMIC_INSTRUMENTATION_ENABLED", + "DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE", "DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE", "DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS", "DD_DYNAMIC_INSTRUMENTATION_REDACTED_TYPES", diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 1f09be9445..d95b122655 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -43,7 +43,7 @@ def initialize(settings:, serializer:, telemetry: nil) # - the `captureExpressions` block ({ name => serialized_value }) # - the per-expression evaluation_errors array def evaluate(probe, context) - budget_ns = settings.dynamic_instrumentation.capture_expression_timeout_ms * 1_000_000 + budget_ns = settings.dynamic_instrumentation.max_time_to_serialize_ms * 1_000_000 deadline_ns = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) + budget_ns output = {} diff --git a/lib/datadog/di/configuration.rb b/lib/datadog/di/configuration.rb index 98ccffe240..9e3baecf58 100644 --- a/lib/datadog/di/configuration.rb +++ b/lib/datadog/di/configuration.rb @@ -134,14 +134,20 @@ def self.add_settings!(base) o.default 20 end - # Per-fire time budget for evaluating capture expressions, - # in milliseconds. Once exhausted, remaining capture + # Time budget (in milliseconds) for serialization work during + # probe fires. Today applied only to capture-expression + # evaluation; once the budget is exhausted, remaining capture # expressions emit a stub entry with # `notCapturedReason: "timeout"` in the snapshot. - option :capture_expression_timeout_ms do |o| + # + # The env var is shared with .NET's whole-snapshot + # serialization budget. Default (200 ms) matches Python's + # hardcoded `CAPTURE_TIME_BUDGET` and .NET's + # `DefaultMaxSerializationTimeInMilliseconds`. + option :max_time_to_serialize_ms do |o| o.type :int o.default 200 - o.env 'DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS' + o.env 'DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE' end # Settings in the 'internal' group are for internal Datadog diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb index 769345c567..a8674766ca 100644 --- a/spec/datadog/di/capture_expression_evaluator_spec.rb +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -15,7 +15,7 @@ default_settings before do - allow(di_settings).to receive(:capture_expression_timeout_ms).and_return(200) + allow(di_settings).to receive(:max_time_to_serialize_ms).and_return(200) end let(:redactor) do @@ -118,7 +118,7 @@ def compile_expression(dsl_string, json) context "time budget exhausted before evaluating any" do before do - allow(di_settings).to receive(:capture_expression_timeout_ms).and_return(0) + allow(di_settings).to receive(:max_time_to_serialize_ms).and_return(0) end let(:probe) do diff --git a/supported-configurations.json b/supported-configurations.json index 7f0ef44d13..20f8d63aff 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -280,18 +280,18 @@ "default": null } ], - "DD_DYNAMIC_INSTRUMENTATION_CAPTURE_TIMEOUT_MS": [ + "DD_DYNAMIC_INSTRUMENTATION_ENABLED": [ { "version": "A", - "type": "int", - "default": "200" + "type": "boolean", + "default": "false" } ], - "DD_DYNAMIC_INSTRUMENTATION_ENABLED": [ + "DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE": [ { "version": "A", - "type": "boolean", - "default": "false" + "type": "int", + "default": "200" } ], "DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE": [ From 720d6eebf4262bc1b42f5d04ccae214cc282f15c Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 17:19:32 -0400 Subject: [PATCH 04/42] DI: capture-expression resolver honors probe-level collection_size and length The CaptureLimits.resolve doc comment promises a per-field fallback chain of expr_limits -> probe -> settings, but for collection_size and length the code went straight from the expression-level override to settings, skipping the probe-level overrides parsed into probe.max_capture_collection_size / probe.max_capture_string_length. That meant a probe-level capture.maxCollectionSize or capture.maxLength from remote config was silently ignored unless repeated on every expression. Adds the probe-level step to both fallback chains so the resolver matches its documented contract. The serializer already accepts length: and collection_size: keyword arguments, so the fix flows end-to-end. Addresses review comments on PR #5845 from @Copilot (3344166066) and @chatgpt-codex-connector (3344167297). Co-Authored-By: Claude --- lib/datadog/di/capture_expression.rb | 2 + spec/datadog/di/capture_expression_spec.rb | 49 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/datadog/di/capture_expression.rb b/lib/datadog/di/capture_expression.rb index 94e69f9ea1..5e1c3cee80 100644 --- a/lib/datadog/di/capture_expression.rb +++ b/lib/datadog/di/capture_expression.rb @@ -65,8 +65,10 @@ def self.resolve(expr_limits:, probe:, settings:) probe.max_capture_depth || di.max_capture_depth, collection_size: expr_limits&.max_collection_size || + probe.max_capture_collection_size || di.max_capture_collection_size, length: expr_limits&.max_length || + probe.max_capture_string_length || di.max_capture_string_length, attribute_count: expr_limits&.max_field_count || probe.max_capture_attribute_count || diff --git a/spec/datadog/di/capture_expression_spec.rb b/spec/datadog/di/capture_expression_spec.rb index 9d56a79f7a..8c440cf6ce 100644 --- a/spec/datadog/di/capture_expression_spec.rb +++ b/spec/datadog/di/capture_expression_spec.rb @@ -57,7 +57,9 @@ let(:probe) do double("probe", max_capture_depth: nil, - max_capture_attribute_count: nil,) + max_capture_attribute_count: nil, + max_capture_collection_size: nil, + max_capture_string_length: nil,) end context "no expression limits and no probe limits" do @@ -69,7 +71,11 @@ context "probe-level limits set" do let(:probe) do - double("probe", max_capture_depth: 7, max_capture_attribute_count: 99) + double("probe", + max_capture_depth: 7, + max_capture_attribute_count: 99, + max_capture_collection_size: nil, + max_capture_string_length: nil,) end it "uses probe limits for depth and attribute_count, settings for the rest" do @@ -78,6 +84,21 @@ end end + context "probe-level collection_size and length set" do + let(:probe) do + double("probe", + max_capture_depth: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: 33, + max_capture_string_length: 77,) + end + + it "uses probe-level overrides for collection_size and length" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 33, length: 77, attribute_count: 20) + end + end + context "per-expression limits set on a subset of fields" do let(:expr_limits) do described_class.new(max_length: 50) @@ -94,7 +115,11 @@ described_class.new(max_reference_depth: 8) end let(:probe) do - double("probe", max_capture_depth: 5, max_capture_attribute_count: 7) + double("probe", + max_capture_depth: 5, + max_capture_attribute_count: 7, + max_capture_collection_size: nil, + max_capture_string_length: nil,) end it "expression wins over probe; probe wins over settings for the rest" do @@ -102,5 +127,23 @@ expect(resolved).to eq(depth: 8, collection_size: 100, length: 255, attribute_count: 7) end end + + context "per-expression length set and probe-level collection_size set" do + let(:expr_limits) do + described_class.new(max_length: 20) + end + let(:probe) do + double("probe", + max_capture_depth: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: 44, + max_capture_string_length: 88,) + end + + it "expression wins over probe for length; probe wins over settings for collection_size" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 44, length: 20, attribute_count: 20) + end + end end end From 46b14dc5a3a049da0138d3443b555b011279d095 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 17:19:39 -0400 Subject: [PATCH 05/42] DI: ProbeBuilder validates captureExpressions type before checking empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_capture_expressions called raw.empty? before verifying raw was an Array. A non-Array value from remote config (e.g. an Integer) would raise NoMethodError instead of the intended ArgumentError, and the outer rescue KeyError in build_from_remote_config would not wrap it either — producing a misleading error class even though the bare rescue in Component#parse_probe_spec_and_notify still kept it from escaping. Reorders the early-return: nil short-circuits, then the Array type check, then the empty-array short-circuit. Adds a probe_builder_spec test for the non-Array case. Addresses review comment on PR #5845 from @Copilot (3344166098). Co-Authored-By: Claude --- lib/datadog/di/probe_builder.rb | 3 ++- spec/datadog/di/probe_builder_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index ae6eb0053e..47e7de1dd8 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -82,10 +82,11 @@ def build_from_remote_config(config) end def build_capture_expressions(raw) - return [] if raw.nil? || raw.empty? + return [] if raw.nil? unless Array === raw raise ArgumentError, "captureExpressions must be an array, got: #{raw.class}" end + return [] if raw.empty? raw.map do |entry| unless Hash === entry raise ArgumentError, "captureExpressions entry must be a hash, got: #{entry.class}" diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index dbdf929e85..b980cee407 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -267,6 +267,16 @@ end end + context "non-Array captureExpressions value" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => 5) + end + + it "raises ArgumentError with the offending type" do + expect { probe }.to raise_error(ArgumentError, /captureExpressions must be an array, got: Integer/) + end + end + context "captureSnapshot=true and non-empty captureExpressions (mutual exclusion at fire time)" do let(:rc_probe_spec) do base_spec.merge( From 1223e4d85821b0c723f2d4bae07f2288c0309542 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 17:19:48 -0400 Subject: [PATCH 06/42] DI: method-probe capture expressions can reference arg/kwarg locals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The return-time Context for method probes was built with locals: nil, so capture expressions referencing arg1 or kwarg names resolved as undefined/nil — the condition path at instrumenter.rb:135 already populates locals via serializer.combine_args(args, kwargs, self) but the return-time path didn't. Passes combine_args(args, kwargs, self) as locals when the probe has capture expressions; keeps nil otherwise to avoid an allocation on every method invocation for probes that don't need it. At return time the args reference is the same Ruby object as at entry, so values reflect any in-place mutation by the method body — matching what conditions already see and what cross-tracer convention reports for method exit scope. Adds an integration test exercising a method probe with a capture expression referencing arg1 against a positional argument. Addresses review comment on PR #5845 from @chatgpt-codex-connector (3344167304). Co-Authored-By: Claude --- lib/datadog/di/instrumenter.rb | 10 ++++- .../di/integration/instrumentation_spec.rb | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index d0768eddc4..c6a7cb8711 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -270,7 +270,15 @@ def hook_method(probe, responder) caller_locs = method_frame + caller_locations # TODO capture arguments at exit - context = Context.new(locals: nil, target_self: self, + # Method-probe capture expressions need access to args/kwargs by + # name (arg1, arg2, kwarg keys). Populate locals from the live + # arg references when the probe has capture expressions; values + # may reflect in-place mutation by the method body, matching the + # return-time scope semantics. + capture_expression_locals = if probe.capture_expressions? + serializer.combine_args(args, kwargs, self) + end + context = Context.new(locals: capture_expression_locals, target_self: self, probe: probe, settings: settings, serializer: serializer, serialized_entry_args: serialized_entry_args, caller_locations: caller_locs, diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 1917ea034c..725f30c0ad 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -560,6 +560,43 @@ def run_test end end + context 'with capture expression referencing a positional argument' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'test_method'}, + captureExpressions: [ + {name: 'arg1', expr: {dsl: 'arg1', json: {ref: 'arg1'}}}, + ], + } + end + + it 'evaluates the expression against arg locals at return time' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationSpecTestClass.new.test_method(7)).to eq(42) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( + "arg1" => {type: 'Integer', value: '7'}, + ) + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "arg1" => {type: 'Integer', value: '7'}, + ) + end + end + context 'when target is invoked' do let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, From 373109496b6dc8c8bb301c47697f95921876f859 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 17:53:32 -0400 Subject: [PATCH 07/42] DI: thread probe-level capture limits through snapshot serialization Probe-level overrides for max_capture_depth, max_capture_attribute_count, max_capture_string_length, and max_capture_collection_size from remote config were partially or fully ignored on every snapshot-path serializer call site: - probe_notification_builder.rb @return: depth and attribute_count flowed through, length and collection_size did not. - probe_notification_builder.rb self (method probe and line probe): none of the four limits were passed; serializer fell back to settings for all of them, silently dropping every probe-level override. - instrumenter.rb entry-args (serialize_args): depth and attribute_count flowed through, length and collection_size did not. - context.rb serialized_locals (serialize_vars): same gap as serialize_args. Adds Probe#snapshot_serializer_limits(settings) returning the four-limit keyword-arg hash with per-field fallback to settings. All five snapshot-path sites now splat it into the serializer call. Serializer#serialize_args and serialize_vars gain matching length / collection_size kwargs that thread to the underlying serialize_value. Adds Probe#snapshot_serializer_limits unit tests covering the no-override, all-override, and mixed-override cases, plus an integration test exercising a max_capture_string_length override end-to-end through entry args, @return, and self's instance variable. Stale doc comments on Probe#max_capture_collection_size and Probe#max_capture_string_length (which claimed the snapshot serializer read settings directly) are removed. Co-Authored-By: Claude --- lib/datadog/di/context.rb | 4 +- lib/datadog/di/instrumenter.rb | 3 +- lib/datadog/di/probe.rb | 21 +++++-- lib/datadog/di/probe_notification_builder.rb | 9 ++- lib/datadog/di/serializer.rb | 14 +++-- sig/datadog/di/probe.rbs | 1 + sig/datadog/di/serializer.rbs | 4 +- spec/datadog/di/instrumenter_spec.rb | 1 + .../di/integration/instrumentation_spec.rb | 34 +++++++++++ spec/datadog/di/probe_spec.rb | 61 +++++++++++++++++++ 10 files changed, 132 insertions(+), 20 deletions(-) diff --git a/lib/datadog/di/context.rb b/lib/datadog/di/context.rb index 61875f63ac..a5eadec389 100644 --- a/lib/datadog/di/context.rb +++ b/lib/datadog/di/context.rb @@ -55,9 +55,7 @@ def initialize(probe:, settings:, serializer:, locals: nil, def serialized_locals # TODO cache? - locals && serializer.serialize_vars(locals, - depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth, - attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count,) + locals && serializer.serialize_vars(locals, **probe.snapshot_serializer_limits(settings)) end def fetch(var_name) diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index c6a7cb8711..d4922ae9e9 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -212,8 +212,7 @@ def hook_method(probe, responder) # they need to be serialized prior to method invocation. serialized_entry_args = if probe.capture_snapshot? serializer.serialize_args(args, kwargs, self, - depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth, - attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count) + **probe.snapshot_serializer_limits(settings)) end # We intentionally do not use Core::Utils::Time.get_time # here because the time provider may be overridden by the diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index ec2926b96d..f87c76e208 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -133,13 +133,11 @@ def initialize(id:, type:, attr_reader :max_capture_attribute_count # Configured maximum collection size. Can be nil in which case the - # global default will be used. Currently only used by capture - # expressions; the snapshot serializer reads the setting directly. + # global default will be used. attr_reader :max_capture_collection_size # Configured maximum string length. Can be nil in which case the - # global default will be used. Currently only used by capture - # expressions; the snapshot serializer reads the setting directly. + # global default will be used. attr_reader :max_capture_string_length # Capture expressions attached to this probe. Empty array when no @@ -173,6 +171,21 @@ def capture_expressions? !@capture_expressions.empty? end + # Returns the four capture-limit keyword arguments for snapshot + # serialization, applying the probe-level override for each field and + # falling back to the DI settings default when the probe-level value + # is nil. The result is intended to be splatted into + # +Serializer#serialize_value+, +serialize_args+, or +serialize_vars+. + def snapshot_serializer_limits(settings) + di = settings.dynamic_instrumentation + { + depth: max_capture_depth || di.max_capture_depth, + attribute_count: max_capture_attribute_count || di.max_capture_attribute_count, + length: max_capture_string_length || di.max_capture_string_length, + collection_size: max_capture_collection_size || di.max_capture_collection_size, + } + end + # Returns whether the probe is a line probe. # # Method probes may still specify a file name (to aid in locating the diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 9c5a4d16b9..e8b1b645bd 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -79,12 +79,11 @@ def build_snapshot(context) # snapshot or vars/args into this method capture_expression_evaluation_errors = [] captures = if probe.capture_snapshot? + snapshot_limits = probe.snapshot_serializer_limits(settings) if probe.method? return_arguments = { - "@return": serializer.serialize_value(context.return_value, - depth: probe.max_capture_depth || settings.dynamic_instrumentation.max_capture_depth, - attribute_count: probe.max_capture_attribute_count || settings.dynamic_instrumentation.max_capture_attribute_count), - self: serializer.serialize_value(context.target_self), + "@return": serializer.serialize_value(context.return_value, **snapshot_limits), + self: serializer.serialize_value(context.target_self, **snapshot_limits), } { entry: { @@ -100,7 +99,7 @@ def build_snapshot(context) lines: (locals = context.serialized_locals) && { probe.line_no => { locals: locals, - arguments: {self: serializer.serialize_value(context.target_self)}, + arguments: {self: serializer.serialize_value(context.target_self, **snapshot_limits)}, }, }, } diff --git a/lib/datadog/di/serializer.rb b/lib/datadog/di/serializer.rb index 2472d3e9e5..b0bf4b9372 100644 --- a/lib/datadog/di/serializer.rb +++ b/lib/datadog/di/serializer.rb @@ -131,9 +131,12 @@ def combine_args(args, kwargs, target_self) # in upstream code. def serialize_args(args, kwargs, target_self, depth: settings.dynamic_instrumentation.max_capture_depth, - attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count) + attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, + length: nil, + collection_size: nil) combined = combine_args(args, kwargs, target_self) - serialize_vars(combined, depth: depth, attribute_count: attribute_count) + serialize_vars(combined, depth: depth, attribute_count: attribute_count, + length: length, collection_size: collection_size) end # Serializes variables captured by a line probe. @@ -142,9 +145,12 @@ def serialize_args(args, kwargs, target_self, # of executed code. def serialize_vars(vars, depth: settings.dynamic_instrumentation.max_capture_depth, - attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count) + attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, + length: nil, + collection_size: nil) vars.each_with_object({}) do |(k, v), agg| - agg[k] = serialize_value(v, name: k, depth: depth, attribute_count: attribute_count) + agg[k] = serialize_value(v, name: k, depth: depth, attribute_count: attribute_count, + length: length, collection_size: collection_size) end end diff --git a/sig/datadog/di/probe.rbs b/sig/datadog/di/probe.rbs index 7195dc3a0f..1fd3d403f0 100644 --- a/sig/datadog/di/probe.rbs +++ b/sig/datadog/di/probe.rbs @@ -69,6 +69,7 @@ module Datadog def capture_snapshot?: () -> bool def capture_expressions?: () -> bool + def snapshot_serializer_limits: (untyped settings) -> Hash[Symbol, Integer] def line?: () -> bool def method?: () -> bool def line_no!: () -> Integer diff --git a/sig/datadog/di/serializer.rbs b/sig/datadog/di/serializer.rbs index 1b6d3171f7..2a69335817 100644 --- a/sig/datadog/di/serializer.rbs +++ b/sig/datadog/di/serializer.rbs @@ -22,8 +22,8 @@ module Datadog attr_reader telemetry: Core::Telemetry::Component? def combine_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped target_self) -> Hash[Symbol, untyped] - def serialize_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped instance_vars, ?depth: Integer, ?attribute_count: Integer?) -> Hash[Symbol, untyped] - def serialize_vars: (Hash[Symbol, untyped] vars, ?depth: Integer, ?attribute_count: Integer?) -> Hash[Symbol, untyped] + def serialize_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped instance_vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, untyped] + def serialize_vars: (Hash[Symbol, untyped] vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, untyped] def serialize_value: (untyped value, ?name: (Symbol | String)?, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?, ?type: Class?) -> Hash[Symbol, untyped] def serialize_value_for_message: (untyped value, ?::Integer depth) -> String? diff --git a/spec/datadog/di/instrumenter_spec.rb b/spec/datadog/di/instrumenter_spec.rb index d553d3e30f..e23be510ed 100644 --- a/spec/datadog/di/instrumenter_spec.rb +++ b/spec/datadog/di/instrumenter_spec.rb @@ -22,6 +22,7 @@ allow(settings.dynamic_instrumentation).to receive(:max_capture_depth).and_return(2) allow(settings.dynamic_instrumentation).to receive(:max_capture_attribute_count).and_return(2) allow(settings.dynamic_instrumentation).to receive(:max_capture_string_length).and_return(100) + allow(settings.dynamic_instrumentation).to receive(:max_capture_collection_size).and_return(100) allow(settings.dynamic_instrumentation).to receive(:redacted_type_names).and_return([]) allow(settings.dynamic_instrumentation).to receive(:redacted_identifiers).and_return([]) allow(settings.dynamic_instrumentation).to receive(:redaction_excluded_identifiers).and_return([]) diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 725f30c0ad..36f9766965 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -597,6 +597,40 @@ def run_test end end + context 'with snapshot capture and probe-level maxLength override' do + let(:probe) do + Datadog::DI::Probe.new(id: "1234", type: :log, + type_name: 'InstrumentationSpecTestClass', method_name: 'mutating_method', + capture_snapshot: true, + max_capture_string_length: 4,) + end + + it 'truncates strings in entry args, return value, and self to the probe-level limit' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationSpecTestClass.new.mutating_method('hello world')).to eq('bye world') + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + # Entry arg passes through serialize_args with the probe-level limit. + expect(captures.fetch(:entry).fetch(:arguments).fetch(:arg1)).to include( + type: 'String', value: 'hell', truncated: true, + ) + # @return passes through serialize_value with the probe-level limit. + expect(captures.fetch(:return).fetch(:arguments).fetch(:@return)).to include( + type: 'String', value: 'bye ', truncated: true, + ) + # self is serialized with the probe-level limit so its ivar string is also truncated. + ivar = captures.fetch(:return).fetch(:arguments).fetch(:self).fetch(:fields).fetch(:@ivar) + expect(ivar).to include(type: 'String', value: 'star', truncated: true) + end + end + context 'when target is invoked' do let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index b40c14daf7..24d0ba4b72 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -275,4 +275,65 @@ end end end + + describe "#snapshot_serializer_limits" do + let(:settings) do + double("settings", dynamic_instrumentation: double( + max_capture_depth: 3, + max_capture_attribute_count: 20, + max_capture_string_length: 255, + max_capture_collection_size: 100, + )) + end + + context "no probe-level overrides" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar") + end + + it "returns all four settings defaults" do + expect(probe.snapshot_serializer_limits(settings)).to eq( + depth: 3, + attribute_count: 20, + length: 255, + collection_size: 100, + ) + end + end + + context "all four probe-level overrides set" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + max_capture_depth: 7, + max_capture_attribute_count: 99, + max_capture_string_length: 77, + max_capture_collection_size: 33,) + end + + it "returns the probe-level values for all four fields" do + expect(probe.snapshot_serializer_limits(settings)).to eq( + depth: 7, + attribute_count: 99, + length: 77, + collection_size: 33, + ) + end + end + + context "mixed probe-level overrides" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + max_capture_string_length: 50,) + end + + it "uses the probe-level value where set, settings default otherwise" do + expect(probe.snapshot_serializer_limits(settings)).to eq( + depth: 3, + attribute_count: 20, + length: 50, + collection_size: 100, + ) + end + end + end end From dd5b75311a564825a4023b5c6eac4c4b29a4f920 Mon Sep 17 00:00:00 2001 From: Unicorn Enterprises Date: Wed, 3 Jun 2026 10:08:33 -0400 Subject: [PATCH 08/42] remove docs --- docs/DynamicInstrumentation.md | 45 ---------------------------------- 1 file changed, 45 deletions(-) diff --git a/docs/DynamicInstrumentation.md b/docs/DynamicInstrumentation.md index 0cc7038063..d170a19495 100644 --- a/docs/DynamicInstrumentation.md +++ b/docs/DynamicInstrumentation.md @@ -146,51 +146,6 @@ supported for Ruby: - Span probes - Dynamic span tags -## Capture Expressions - -Log probes can carry a list of *capture expressions*: named DSL -expressions whose evaluated values are emitted in the snapshot under -the `captureExpressions` block. Capture expressions are an alternative -to `captureSnapshot: true` — they let you pick exactly which values to -capture instead of the whole local/argument scope. - -Each capture expression carries: - -- `name` — the key used in the snapshot output. Must match - `^[a-zA-Z0-9_?]+$`. -- `expr` — a DSL expression evaluated against the probe scope (uses - the same expression language as `when` conditions and message - templates). -- `capture` (optional) — per-expression - `maxReferenceDepth` / `maxCollectionSize` / `maxLength` / - `maxFieldCount` override. - -Behavior notes for Ruby: - -- **Snapshot vs. capture expressions are mutually exclusive at fire - time.** If a probe sets both `captureSnapshot: true` and a non-empty - `captureExpressions`, the full snapshot is emitted and the - capture-expression values are dropped. This matches the - Python/Java/Go behavior and supports graceful degradation for older - tracer versions that lack capture-expression support. -- **Per-field fallback.** Limits absent from a per-expression - `capture` block fall back to the probe-level `capture` and then to - the tracer's default settings, independently per field. -- **Rate-limit default.** A probe with `captureExpressions` set - defaults to the snapshot bucket of 1 invocation/second, not the - log-message bucket. Override with `sampling.snapshotsPerSecond` on - the probe. -- **Per-fire time budget.** Capture-expression evaluation for one - probe fire is bounded by `DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE` - (default `200` ms). When exceeded, remaining expressions are - emitted with `"notCapturedReason": "timeout"` so the truncation is - visible in the snapshot UI. This env var is shared with the .NET - tracer's whole-snapshot serialization budget. -- **Evaluation errors are visible.** When an individual expression - fails to evaluate, its key is omitted from `captureExpressions` - and an `{ expr: , message: … }` entry is added to the - snapshot's `evaluationErrors` array. - ## Expression Language The Ruby tracer supports Dynamic Instrumentation expression language for From f4e4a52b2522ed27c1e0f9662793d9aa411de23e Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 16:07:07 -0400 Subject: [PATCH 09/42] Wire telemetry through capture-expression evaluator and fix probe-level limit fallback Address review feedback on the capture-expressions PR: 1. CaptureLimits.resolve now consults probe.max_capture_collection_size and probe.max_capture_string_length, matching the customer-facing documentation ("fall back to the probe-level capture and then to the tracer's default settings, independently per field"). Previously those two probe-level attributes were stored but never read. 2. ProbeNotificationBuilder now accepts and forwards telemetry to CaptureExpressionEvaluator. Component wires telemetry through at construction. Previously the evaluator's rescue block and timeout counter were no-ops because @telemetry was always nil. 3. CaptureExpressionEvaluator#evaluate uses the correct telemetry.inc signature: (namespace, metric_name, value). The previous 2-argument form would have raised ArgumentError once telemetry was actually wired through. 4. Added end-to-end ProbeNotificationBuilder#build_snapshot tests for: - line probe with capture_expressions emits captureExpressions block - method probe with capture_expressions emits entry/return blocks - method probe with capture_expressions and an exception emits throwable - captureSnapshot=true wins and drops captureExpressions - per-expression evaluation errors flow into top-level evaluationErrors 5. Added branch coverage for ProbeBuilder.build_capture_expressions / build_capture_limits type-check raises (non-array captureExpressions, non-hash entry, non-hash capture). 6. Documented DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE and the programmatic max_time_to_serialize_ms setting in GettingStarted.md. 7. capture_expression_spec.rb now uses instance_double(Datadog::DI::Probe) instead of plain double() so interface drift is caught. Co-Authored-By: Claude Opus 4.7 --- docs/GettingStarted.md | 2 + .../di/capture_expression_evaluator.rb | 4 +- lib/datadog/di/component.rb | 2 +- lib/datadog/di/probe_notification_builder.rb | 6 +- .../di/capture_expression_evaluator.rbs | 2 + sig/datadog/di/probe_notification_builder.rbs | 4 +- spec/datadog/di/capture_expression_spec.rb | 11 +- spec/datadog/di/probe_builder_spec.rb | 32 ++++ .../di/probe_notification_builder_spec.rb | 139 ++++++++++++++++++ 9 files changed, 192 insertions(+), 10 deletions(-) diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index 8584cddeed..3ae9083cbe 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -2958,6 +2958,7 @@ Dynamic Instrumentation allows you to add log probes, metric probes, and span pr | `DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS` | `Array` | Comma-separated list of variable/key names to redact in addition to the built-in list. Names are normalized (underscores, dashes, `@`, `$` removed) and matched case-insensitively. | `[]` | | `DD_DYNAMIC_INSTRUMENTATION_REDACTION_EXCLUDED_IDENTIFIERS` | `Array` | Comma-separated list of variable/key names to exclude from the built-in redaction list, allowing their values to be captured. | `[]` | | `DD_DYNAMIC_INSTRUMENTATION_REDACTED_TYPES` | `Array` | Comma-separated list of class names whose values will be redacted. Suffix with `*` for wildcard matching (for example, `Foo*` redacts `Foo`, `FooBar`, `Foo::Bar`). | `[]` | +| `DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE` | `Integer` | Time budget (in milliseconds) per probe fire for capture-expression evaluation. Once exhausted, remaining capture expressions emit a `notCapturedReason: "timeout"` stub in the snapshot. Shared with the .NET tracer's whole-snapshot serialization budget. | `200` | Alternatively, set DI parameters inside a `Datadog.configure` block: @@ -2967,6 +2968,7 @@ Alternatively, set DI parameters inside a `Datadog.configure` block: | `c.dynamic_instrumentation.redacted_identifiers` | `Array` | Variable/key names to redact in addition to the built-in list. | `[]` | | `c.dynamic_instrumentation.redaction_excluded_identifiers` | `Array` | Variable/key names to exclude from the built-in redaction list. | `[]` | | `c.dynamic_instrumentation.redacted_type_names` | `Array` | Class names whose values will be redacted. Suffix with `*` for wildcard. | `[]` | +| `c.dynamic_instrumentation.max_time_to_serialize_ms` | `Integer` | Per-probe-fire time budget (in milliseconds) for capture-expression evaluation. | `200` | #### Symbol Database diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index d95b122655..9afe50d8ac 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -22,6 +22,8 @@ module DI # # @api private class CaptureExpressionEvaluator + TELEMETRY_NAMESPACE = "dynamic_instrumentation" + # @param settings [Datadog::Core::Configuration::Settings] # @param serializer [Datadog::DI::Serializer] # @param telemetry [Datadog::Core::Telemetry::Component, nil] @@ -54,7 +56,7 @@ def evaluate(probe, context) if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns output[name] = {notCapturedReason: "timeout"} - telemetry&.inc("dynamic_instrumentation.capture_expression_timeout", 1) + telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) next end diff --git a/lib/datadog/di/component.rb b/lib/datadog/di/component.rb index c8f5bf466b..331bbde74d 100644 --- a/lib/datadog/di/component.rb +++ b/lib/datadog/di/component.rb @@ -76,7 +76,7 @@ def initialize(settings, agent_settings, logger, code_tracker: nil, telemetry: n @serializer = Serializer.new(settings, redactor, telemetry: telemetry) @instrumenter = Instrumenter.new(settings, serializer, logger, code_tracker: code_tracker, telemetry: telemetry) @probe_repository = ProbeRepository.new - @probe_notification_builder = ProbeNotificationBuilder.new(settings, serializer) + @probe_notification_builder = ProbeNotificationBuilder.new(settings, serializer, telemetry: telemetry) @probe_notifier_worker = ProbeNotifierWorker.new( settings, logger, agent_settings: agent_settings, diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index e8b1b645bd..905a2120ac 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -11,16 +11,18 @@ module DI # # @api private class ProbeNotificationBuilder - def initialize(settings, serializer) + def initialize(settings, serializer, telemetry: nil) @settings = settings @serializer = serializer + @telemetry = telemetry @capture_expression_evaluator = CaptureExpressionEvaluator.new( - settings: settings, serializer: serializer, + settings: settings, serializer: serializer, telemetry: telemetry, ) end attr_reader :settings attr_reader :serializer + attr_reader :telemetry attr_reader :capture_expression_evaluator def build_received(probe) diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs index 596f9efddc..e1ab395fce 100644 --- a/sig/datadog/di/capture_expression_evaluator.rbs +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -1,6 +1,8 @@ module Datadog module DI class CaptureExpressionEvaluator + TELEMETRY_NAMESPACE: String + @settings: untyped @serializer: Datadog::DI::Serializer @telemetry: untyped? diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index e0ebfae960..648fd6db5d 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -7,12 +7,14 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer + @telemetry: untyped? @capture_expression_evaluator: CaptureExpressionEvaluator - def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer) -> void + def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, ?telemetry: untyped?) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Serializer + attr_reader telemetry: untyped? attr_reader capture_expression_evaluator: CaptureExpressionEvaluator def build_received: (Probe probe) -> Hash[Symbol,untyped] diff --git a/spec/datadog/di/capture_expression_spec.rb b/spec/datadog/di/capture_expression_spec.rb index 8c440cf6ce..7e2eeb7855 100644 --- a/spec/datadog/di/capture_expression_spec.rb +++ b/spec/datadog/di/capture_expression_spec.rb @@ -1,5 +1,6 @@ require "datadog/di/spec_helper" require "datadog/di/capture_expression" +require "datadog/di/probe" RSpec.describe Datadog::DI::CaptureExpression do di_test @@ -55,7 +56,7 @@ )) end let(:probe) do - double("probe", + instance_double(Datadog::DI::Probe, max_capture_depth: nil, max_capture_attribute_count: nil, max_capture_collection_size: nil, @@ -71,7 +72,7 @@ context "probe-level limits set" do let(:probe) do - double("probe", + instance_double(Datadog::DI::Probe, max_capture_depth: 7, max_capture_attribute_count: 99, max_capture_collection_size: nil, @@ -86,7 +87,7 @@ context "probe-level collection_size and length set" do let(:probe) do - double("probe", + instance_double(Datadog::DI::Probe, max_capture_depth: nil, max_capture_attribute_count: nil, max_capture_collection_size: 33, @@ -115,7 +116,7 @@ described_class.new(max_reference_depth: 8) end let(:probe) do - double("probe", + instance_double(Datadog::DI::Probe, max_capture_depth: 5, max_capture_attribute_count: 7, max_capture_collection_size: nil, @@ -133,7 +134,7 @@ described_class.new(max_length: 20) end let(:probe) do - double("probe", + instance_double(Datadog::DI::Probe, max_capture_depth: nil, max_capture_attribute_count: nil, max_capture_collection_size: 44, diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index b980cee407..efa1c5b1e3 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -297,6 +297,38 @@ probe end end + + context "captureExpressions is not an array" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => {"name" => "x"}) + end + + it "raises ArgumentError" do + expect { probe }.to raise_error(ArgumentError, /captureExpressions must be an array/) + end + end + + context "captureExpressions entry is not a hash" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => ["not-a-hash"]) + end + + it "raises ArgumentError" do + expect { probe }.to raise_error(ArgumentError, /captureExpressions entry must be a hash/) + end + end + + context "per-expression capture block is not a hash" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [ + valid_capture_expression.merge("capture" => "not-a-hash"), + ]) + end + + it "raises ArgumentError" do + expect { probe }.to raise_error(ArgumentError, /capture-expression entry capture must be a hash/) + end + end end end end diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index fa6288d077..b1776e029c 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -2,6 +2,8 @@ require 'datadog/di/probe_notification_builder' require 'datadog/di/serializer' require 'datadog/di/probe' +require 'datadog/di/capture_expression' +require 'datadog/di/el' # Notification builder is primarily tested via integration tests for # dynamic instrumentation overall, since the generated payloads depend @@ -34,6 +36,7 @@ allow(settings).to receive(:max_capture_attribute_count).and_return(10) allow(settings).to receive(:max_capture_depth).and_return(2) allow(settings).to receive(:max_capture_string_length).and_return(100) + allow(settings).to receive(:max_time_to_serialize_ms).and_return(200) end end @@ -703,6 +706,142 @@ end end + describe '#build_snapshot with capture_expressions' do + let(:compiled_expr) do + compiled = Datadog::DI::EL::Compiler.new.compile({"ref" => "x"}) + Datadog::DI::EL::Expression.new("x", compiled) + end + + let(:capture_expression) do + Datadog::DI::CaptureExpression.new(name: "x", expr: compiled_expr) + end + + let(:context) do + Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, + locals: {x: 42}, + target_self: nil, + ) + end + + context 'line probe with only capture_expressions' do + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, file: 'X', line_no: 1, + capture_expressions: [capture_expression]) + end + + it 'emits captureExpressions under the line block' do + payload = builder.build_snapshot(context) + lines = payload[:debugger][:snapshot][:captures][:lines] + expect(lines.keys).to eq([1]) + expect(lines[1][:captureExpressions].keys).to eq(["x"]) + expect(lines[1][:captureExpressions]["x"]).to include(type: "Integer", value: "42") + end + + it 'does not emit locals or arguments blocks alongside captureExpressions' do + payload = builder.build_snapshot(context) + lines = payload[:debugger][:snapshot][:captures][:lines] + expect(lines[1]).not_to have_key(:locals) + expect(lines[1]).not_to have_key(:arguments) + end + end + + context 'method probe with only capture_expressions' do + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, type_name: 'Foo', method_name: 'bar', + capture_expressions: [capture_expression]) + end + + it 'emits captureExpressions under both entry and return blocks' do + payload = builder.build_snapshot(context) + captures = payload[:debugger][:snapshot][:captures] + expect(captures[:entry][:captureExpressions].keys).to eq(["x"]) + expect(captures[:return][:captureExpressions].keys).to eq(["x"]) + expect(captures[:entry][:captureExpressions]["x"]).to include(type: "Integer", value: "42") + end + end + + context 'method probe with capture_expressions and an exception in context' do + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, type_name: 'Foo', method_name: 'bar', + capture_expressions: [capture_expression]) + end + + let(:raised_exception) do + raise "boom" + rescue => e + e + end + + let(:context) do + Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, + locals: {x: 42}, + target_self: Object.new, + exception: raised_exception, + ) + end + + it 'emits throwable in the return block alongside captureExpressions' do + payload = builder.build_snapshot(context) + return_block = payload[:debugger][:snapshot][:captures][:return] + expect(return_block[:captureExpressions].keys).to eq(["x"]) + expect(return_block[:throwable][:type]).to eq("RuntimeError") + end + end + + context 'captureSnapshot wins when both are set' do + let(:target_self) { Object.new } + + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, type_name: 'Foo', method_name: 'bar', + capture_snapshot: true, + capture_expressions: [capture_expression]) + end + + let(:context) do + Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, + target_self: target_self, + return_value: 99, + ) + end + + it 'emits the snapshot blocks and omits captureExpressions' do + payload = builder.build_snapshot(context) + captures = payload[:debugger][:snapshot][:captures] + expect(captures[:return][:arguments]).to be_a(Hash) + expect(captures[:entry]).not_to have_key(:captureExpressions) + expect(captures[:return]).not_to have_key(:captureExpressions) + end + end + + context 'evaluation error in a capture expression' do + let(:failing_expr) do + # len(badvar) — badvar is not in locals, resolves to nil, len(nil) raises + compiled = Datadog::DI::EL::Compiler.new.compile({"len" => {"ref" => "badvar"}}) + Datadog::DI::EL::Expression.new("len(badvar)", compiled) + end + + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, file: 'X', line_no: 1, + capture_expressions: [Datadog::DI::CaptureExpression.new(name: "bad", expr: failing_expr)]) + end + + it 'omits the failing key from captureExpressions and reports in top-level evaluationErrors' do + payload = builder.build_snapshot(context) + snapshot = payload[:debugger][:snapshot] + expect(snapshot[:captures][:lines][1][:captureExpressions]).to eq({}) + expect(snapshot[:evaluationErrors].size).to eq(1) + expect(snapshot[:evaluationErrors].first[:expr]).to eq("bad") + expect(snapshot[:evaluationErrors].first[:message]).to include("ExpressionEvaluationError") + end + end + end + describe 'process tags' do let(:probe) do Datadog::DI::Probe.new(id: '123', type: :log, file: 'X', line_no: 1) From f2a676a454cf996d5e83a27366cf0dabbef92894 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 2 Jun 2026 16:18:57 -0400 Subject: [PATCH 10/42] Split CaptureLimits into its own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the dd-trace-rb convention of one class per file. The DI codebase otherwise has Probe in probe.rb, Serializer in serializer.rb, etc. capture_expression.rb was the only DI file with two classes. - lib/datadog/di/capture_limits.rb — new, CaptureLimits only - lib/datadog/di/capture_expression.rb — CaptureExpression only, requires capture_limits for the limits attribute type - sig split into capture_limits.rbs and capture_expression.rbs - spec/datadog/di/capture_limits_spec.rb — new, CaptureLimits tests - capture_expression_spec.rb — CaptureExpression tests only - boot.rb requires capture_limits before capture_expression - capture_expression_evaluator.rb and probe_builder.rb add explicit capture_limits requires (they were getting it transitively via capture_expression.rb) Co-Authored-By: Claude Opus 4.7 --- lib/datadog/di/boot.rb | 1 + lib/datadog/di/capture_expression.rb | 54 +------ .../di/capture_expression_evaluator.rb | 1 + lib/datadog/di/capture_limits.rb | 57 ++++++++ lib/datadog/di/probe_builder.rb | 1 + sig/datadog/di/capture_expression.rbs | 16 --- sig/datadog/di/capture_limits.rbs | 19 +++ spec/datadog/di/capture_expression_spec.rb | 129 ----------------- spec/datadog/di/capture_limits_spec.rb | 132 ++++++++++++++++++ 9 files changed, 213 insertions(+), 197 deletions(-) create mode 100644 lib/datadog/di/capture_limits.rb create mode 100644 sig/datadog/di/capture_limits.rbs create mode 100644 spec/datadog/di/capture_limits_spec.rb diff --git a/lib/datadog/di/boot.rb b/lib/datadog/di/boot.rb index f8422f5de0..00ac2f1154 100644 --- a/lib/datadog/di/boot.rb +++ b/lib/datadog/di/boot.rb @@ -6,6 +6,7 @@ require_relative 'code_tracker' require_relative 'component' require_relative 'context' +require_relative 'capture_limits' require_relative 'capture_expression' require_relative 'capture_expression_evaluator' require_relative 'instrumenter' diff --git a/lib/datadog/di/capture_expression.rb b/lib/datadog/di/capture_expression.rb index 5e1c3cee80..7e6aca3497 100644 --- a/lib/datadog/di/capture_expression.rb +++ b/lib/datadog/di/capture_expression.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "capture_limits" + module Datadog module DI # Configured capture expression on a log probe. @@ -24,57 +26,5 @@ def initialize(name:, expr:, limits: nil) attr_reader :expr attr_reader :limits end - - # Per-expression CaptureLimits override. - # - # Each field is independently optional. The +resolve+ class method - # walks a per-field fallback chain - # (expression -> probe -> language default) and returns a fully - # populated set of limits to pass to the serializer. - # - # @api private - class CaptureLimits - def initialize(max_reference_depth: nil, max_collection_size: nil, - max_length: nil, max_field_count: nil) - @max_reference_depth = max_reference_depth - @max_collection_size = max_collection_size - @max_length = max_length - @max_field_count = max_field_count - end - - attr_reader :max_reference_depth - attr_reader :max_collection_size - attr_reader :max_length - attr_reader :max_field_count - - # Resolves effective limits for a capture expression by walking - # the per-field fallback chain: - # - # expr_limits.X ?? probe.max_capture_X ?? settings.dynamic_instrumentation.max_capture_X - # - # Resolved independently per field — a missing field on the - # expression's limits falls through to the probe level, then to - # the language-wide default, without affecting the other fields. - # - # Returns a Hash with keys :depth, :collection_size, :length, - # :attribute_count to match the serializer's keyword arguments. - def self.resolve(expr_limits:, probe:, settings:) - di = settings.dynamic_instrumentation - { - depth: expr_limits&.max_reference_depth || - probe.max_capture_depth || - di.max_capture_depth, - collection_size: expr_limits&.max_collection_size || - probe.max_capture_collection_size || - di.max_capture_collection_size, - length: expr_limits&.max_length || - probe.max_capture_string_length || - di.max_capture_string_length, - attribute_count: expr_limits&.max_field_count || - probe.max_capture_attribute_count || - di.max_capture_attribute_count, - } - end - end end end diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 9afe50d8ac..db84c530a1 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "capture_expression" +require_relative "capture_limits" module Datadog module DI diff --git a/lib/datadog/di/capture_limits.rb b/lib/datadog/di/capture_limits.rb new file mode 100644 index 0000000000..28854c9a5f --- /dev/null +++ b/lib/datadog/di/capture_limits.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Datadog + module DI + # Per-expression capture limits override. + # + # Each field is independently optional. The +resolve+ class method + # walks a per-field fallback chain + # (expression -> probe -> language default) and returns a fully + # populated set of limits to pass to the serializer. + # + # @api private + class CaptureLimits + def initialize(max_reference_depth: nil, max_collection_size: nil, + max_length: nil, max_field_count: nil) + @max_reference_depth = max_reference_depth + @max_collection_size = max_collection_size + @max_length = max_length + @max_field_count = max_field_count + end + + attr_reader :max_reference_depth + attr_reader :max_collection_size + attr_reader :max_length + attr_reader :max_field_count + + # Resolves effective limits for a capture expression by walking + # the per-field fallback chain: + # + # expr_limits.X ?? probe.max_capture_X ?? settings.dynamic_instrumentation.max_capture_X + # + # Resolved independently per field — a missing field on the + # expression's limits falls through to the probe level, then to + # the language-wide default, without affecting the other fields. + # + # Returns a Hash with keys :depth, :collection_size, :length, + # :attribute_count to match the serializer's keyword arguments. + def self.resolve(expr_limits:, probe:, settings:) + di = settings.dynamic_instrumentation + { + depth: expr_limits&.max_reference_depth || + probe.max_capture_depth || + di.max_capture_depth, + collection_size: expr_limits&.max_collection_size || + probe.max_capture_collection_size || + di.max_capture_collection_size, + length: expr_limits&.max_length || + probe.max_capture_string_length || + di.max_capture_string_length, + attribute_count: expr_limits&.max_field_count || + probe.max_capture_attribute_count || + di.max_capture_attribute_count, + } + end + end + end +end diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 47e7de1dd8..8fc329d53f 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -4,6 +4,7 @@ require_relative "probe" require_relative "capture_expression" +require_relative "capture_limits" require_relative 'el' module Datadog diff --git a/sig/datadog/di/capture_expression.rbs b/sig/datadog/di/capture_expression.rbs index c3622c6435..486a2124bc 100644 --- a/sig/datadog/di/capture_expression.rbs +++ b/sig/datadog/di/capture_expression.rbs @@ -11,21 +11,5 @@ module Datadog attr_reader expr: DI::EL::Expression attr_reader limits: CaptureLimits? end - - class CaptureLimits - @max_reference_depth: Integer? - @max_collection_size: Integer? - @max_length: Integer? - @max_field_count: Integer? - - def initialize: (?max_reference_depth: Integer?, ?max_collection_size: Integer?, ?max_length: Integer?, ?max_field_count: Integer?) -> void - - attr_reader max_reference_depth: Integer? - attr_reader max_collection_size: Integer? - attr_reader max_length: Integer? - attr_reader max_field_count: Integer? - - def self.resolve: (expr_limits: CaptureLimits?, probe: Probe, settings: untyped) -> Hash[Symbol, Integer] - end end end diff --git a/sig/datadog/di/capture_limits.rbs b/sig/datadog/di/capture_limits.rbs new file mode 100644 index 0000000000..a3f2d829fc --- /dev/null +++ b/sig/datadog/di/capture_limits.rbs @@ -0,0 +1,19 @@ +module Datadog + module DI + class CaptureLimits + @max_reference_depth: Integer? + @max_collection_size: Integer? + @max_length: Integer? + @max_field_count: Integer? + + def initialize: (?max_reference_depth: Integer?, ?max_collection_size: Integer?, ?max_length: Integer?, ?max_field_count: Integer?) -> void + + attr_reader max_reference_depth: Integer? + attr_reader max_collection_size: Integer? + attr_reader max_length: Integer? + attr_reader max_field_count: Integer? + + def self.resolve: (expr_limits: CaptureLimits?, probe: Probe, settings: untyped) -> Hash[Symbol, Integer] + end + end +end diff --git a/spec/datadog/di/capture_expression_spec.rb b/spec/datadog/di/capture_expression_spec.rb index 7e2eeb7855..ec0bc2ad3c 100644 --- a/spec/datadog/di/capture_expression_spec.rb +++ b/spec/datadog/di/capture_expression_spec.rb @@ -1,6 +1,5 @@ require "datadog/di/spec_helper" require "datadog/di/capture_expression" -require "datadog/di/probe" RSpec.describe Datadog::DI::CaptureExpression do di_test @@ -20,131 +19,3 @@ end end -RSpec.describe Datadog::DI::CaptureLimits do - di_test - - describe ".new" do - it "stores all four fields" do - cl = described_class.new( - max_reference_depth: 5, - max_collection_size: 50, - max_length: 100, - max_field_count: 10, - ) - expect(cl.max_reference_depth).to eq(5) - expect(cl.max_collection_size).to eq(50) - expect(cl.max_length).to eq(100) - expect(cl.max_field_count).to eq(10) - end - - it "defaults all four fields to nil" do - cl = described_class.new - expect(cl.max_reference_depth).to be_nil - expect(cl.max_collection_size).to be_nil - expect(cl.max_length).to be_nil - expect(cl.max_field_count).to be_nil - end - end - - describe ".resolve" do - let(:settings) do - double("settings", dynamic_instrumentation: double( - max_capture_depth: 3, - max_capture_collection_size: 100, - max_capture_string_length: 255, - max_capture_attribute_count: 20, - )) - end - let(:probe) do - instance_double(Datadog::DI::Probe, - max_capture_depth: nil, - max_capture_attribute_count: nil, - max_capture_collection_size: nil, - max_capture_string_length: nil,) - end - - context "no expression limits and no probe limits" do - it "falls back to settings for all four fields" do - resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) - expect(resolved).to eq(depth: 3, collection_size: 100, length: 255, attribute_count: 20) - end - end - - context "probe-level limits set" do - let(:probe) do - instance_double(Datadog::DI::Probe, - max_capture_depth: 7, - max_capture_attribute_count: 99, - max_capture_collection_size: nil, - max_capture_string_length: nil,) - end - - it "uses probe limits for depth and attribute_count, settings for the rest" do - resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) - expect(resolved).to eq(depth: 7, collection_size: 100, length: 255, attribute_count: 99) - end - end - - context "probe-level collection_size and length set" do - let(:probe) do - instance_double(Datadog::DI::Probe, - max_capture_depth: nil, - max_capture_attribute_count: nil, - max_capture_collection_size: 33, - max_capture_string_length: 77,) - end - - it "uses probe-level overrides for collection_size and length" do - resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) - expect(resolved).to eq(depth: 3, collection_size: 33, length: 77, attribute_count: 20) - end - end - - context "per-expression limits set on a subset of fields" do - let(:expr_limits) do - described_class.new(max_length: 50) - end - - it "uses expression limit for the set field; probe / settings for the rest" do - resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) - expect(resolved).to eq(depth: 3, collection_size: 100, length: 50, attribute_count: 20) - end - end - - context "per-expression limits and probe limits both set" do - let(:expr_limits) do - described_class.new(max_reference_depth: 8) - end - let(:probe) do - instance_double(Datadog::DI::Probe, - max_capture_depth: 5, - max_capture_attribute_count: 7, - max_capture_collection_size: nil, - max_capture_string_length: nil,) - end - - it "expression wins over probe; probe wins over settings for the rest" do - resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) - expect(resolved).to eq(depth: 8, collection_size: 100, length: 255, attribute_count: 7) - end - end - - context "per-expression length set and probe-level collection_size set" do - let(:expr_limits) do - described_class.new(max_length: 20) - end - let(:probe) do - instance_double(Datadog::DI::Probe, - max_capture_depth: nil, - max_capture_attribute_count: nil, - max_capture_collection_size: 44, - max_capture_string_length: 88,) - end - - it "expression wins over probe for length; probe wins over settings for collection_size" do - resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) - expect(resolved).to eq(depth: 3, collection_size: 44, length: 20, attribute_count: 20) - end - end - end -end diff --git a/spec/datadog/di/capture_limits_spec.rb b/spec/datadog/di/capture_limits_spec.rb new file mode 100644 index 0000000000..47e72ed762 --- /dev/null +++ b/spec/datadog/di/capture_limits_spec.rb @@ -0,0 +1,132 @@ +require "datadog/di/spec_helper" +require "datadog/di/capture_limits" +require "datadog/di/probe" + +RSpec.describe Datadog::DI::CaptureLimits do + di_test + + describe ".new" do + it "stores all four fields" do + cl = described_class.new( + max_reference_depth: 5, + max_collection_size: 50, + max_length: 100, + max_field_count: 10, + ) + expect(cl.max_reference_depth).to eq(5) + expect(cl.max_collection_size).to eq(50) + expect(cl.max_length).to eq(100) + expect(cl.max_field_count).to eq(10) + end + + it "defaults all four fields to nil" do + cl = described_class.new + expect(cl.max_reference_depth).to be_nil + expect(cl.max_collection_size).to be_nil + expect(cl.max_length).to be_nil + expect(cl.max_field_count).to be_nil + end + end + + describe ".resolve" do + let(:settings) do + double("settings", dynamic_instrumentation: double( + max_capture_depth: 3, + max_capture_collection_size: 100, + max_capture_string_length: 255, + max_capture_attribute_count: 20, + )) + end + let(:probe) do + instance_double(Datadog::DI::Probe, + max_capture_depth: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: nil, + max_capture_string_length: nil,) + end + + context "no expression limits and no probe limits" do + it "falls back to settings for all four fields" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 100, length: 255, attribute_count: 20) + end + end + + context "probe-level limits set" do + let(:probe) do + instance_double(Datadog::DI::Probe, + max_capture_depth: 7, + max_capture_attribute_count: 99, + max_capture_collection_size: nil, + max_capture_string_length: nil,) + end + + it "uses probe limits for depth and attribute_count, settings for the rest" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 7, collection_size: 100, length: 255, attribute_count: 99) + end + end + + context "probe-level collection_size and length set" do + let(:probe) do + instance_double(Datadog::DI::Probe, + max_capture_depth: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: 33, + max_capture_string_length: 77,) + end + + it "uses probe-level overrides for collection_size and length" do + resolved = described_class.resolve(expr_limits: nil, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 33, length: 77, attribute_count: 20) + end + end + + context "per-expression limits set on a subset of fields" do + let(:expr_limits) do + described_class.new(max_length: 50) + end + + it "uses expression limit for the set field; probe / settings for the rest" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 100, length: 50, attribute_count: 20) + end + end + + context "per-expression limits and probe limits both set" do + let(:expr_limits) do + described_class.new(max_reference_depth: 8) + end + let(:probe) do + instance_double(Datadog::DI::Probe, + max_capture_depth: 5, + max_capture_attribute_count: 7, + max_capture_collection_size: nil, + max_capture_string_length: nil,) + end + + it "expression wins over probe; probe wins over settings for the rest" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 8, collection_size: 100, length: 255, attribute_count: 7) + end + end + + context "per-expression length set and probe-level collection_size set" do + let(:expr_limits) do + described_class.new(max_length: 20) + end + let(:probe) do + instance_double(Datadog::DI::Probe, + max_capture_depth: nil, + max_capture_attribute_count: nil, + max_capture_collection_size: 44, + max_capture_string_length: 88,) + end + + it "expression wins over probe for length; probe wins over settings for collection_size" do + resolved = described_class.resolve(expr_limits: expr_limits, probe: probe, settings: settings) + expect(resolved).to eq(depth: 3, collection_size: 44, length: 20, attribute_count: 20) + end + end + end +end From 16784b7c0afc5582d06d701bde0932ebfa6aa7b3 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 10:30:32 -0400 Subject: [PATCH 11/42] DI: inject logger/telemetry into capture-expression evaluator + require telemetry on notification builder Addresses review feedback on the capture-expressions PR: 1. Component pattern violation: CaptureExpressionEvaluator was calling Datadog.logger.debug on the rescue path despite already taking injected settings/serializer/telemetry. Logger is now a required constructor parameter; ProbeNotificationBuilder forwards it (and is itself given logger via Component#initialize, matching the existing Instrumenter signature). 2. Nil-defaulted keyword arguments: telemetry was `telemetry: nil` on both CaptureExpressionEvaluator and ProbeNotificationBuilder, but production always supplies a real telemetry component (Component passes it through). The default was test convenience, not a real optional dependency. Both telemetry: kwargs are now required. 3. Telemetry assertions: capture_expression_evaluator_spec now uses instance doubles for logger and telemetry and asserts the two telemetry calls (timeout -> telemetry.inc(NAMESPACE, "capture_expression_timeout", 1); rescue -> telemetry.report(exc, description: "DI capture-expression evaluation failed")) plus the corresponding logger.debug call on the rescue path. The two failing-edge tests previously relied on telemetry's nil default, so the rescue and timeout branches were never verified. Co-Authored-By: Claude --- .../di/capture_expression_evaluator.rb | 13 +++++++---- lib/datadog/di/component.rb | 2 +- lib/datadog/di/probe_notification_builder.rb | 6 +++-- .../di/capture_expression_evaluator.rbs | 8 ++++--- sig/datadog/di/probe_notification_builder.rbs | 8 ++++--- .../di/capture_expression_evaluator_spec.rb | 23 ++++++++++++++++++- .../di/instrumenter_circuit_breaker_spec.rb | 5 +++- .../di/probe_notification_builder_spec.rb | 5 +++- 8 files changed, 53 insertions(+), 17 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index db84c530a1..248c91f485 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -27,15 +27,18 @@ class CaptureExpressionEvaluator # @param settings [Datadog::Core::Configuration::Settings] # @param serializer [Datadog::DI::Serializer] - # @param telemetry [Datadog::Core::Telemetry::Component, nil] - def initialize(settings:, serializer:, telemetry: nil) + # @param logger [Datadog::Core::Logger] + # @param telemetry [Datadog::Core::Telemetry::Component] + def initialize(settings:, serializer:, logger:, telemetry:) @settings = settings @serializer = serializer + @logger = logger @telemetry = telemetry end attr_reader :settings attr_reader :serializer + attr_reader :logger attr_reader :telemetry # Evaluate +probe.capture_expressions+ against +context+. @@ -57,7 +60,7 @@ def evaluate(probe, context) if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns output[name] = {notCapturedReason: "timeout"} - telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) + telemetry.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) next end @@ -77,10 +80,10 @@ def evaluate(probe, context) ) rescue => exc evaluation_errors << {expr: name, message: "#{exc.class}: #{exc.message}"} - Datadog.logger.debug do + logger.debug do "di: probe #{probe.id}: capture expression #{name}: evaluation failed: #{exc.class}: #{exc.message}" end - telemetry&.report(exc, description: "DI capture-expression evaluation failed") + telemetry.report(exc, description: "DI capture-expression evaluation failed") end end diff --git a/lib/datadog/di/component.rb b/lib/datadog/di/component.rb index 331bbde74d..6a5dede431 100644 --- a/lib/datadog/di/component.rb +++ b/lib/datadog/di/component.rb @@ -76,7 +76,7 @@ def initialize(settings, agent_settings, logger, code_tracker: nil, telemetry: n @serializer = Serializer.new(settings, redactor, telemetry: telemetry) @instrumenter = Instrumenter.new(settings, serializer, logger, code_tracker: code_tracker, telemetry: telemetry) @probe_repository = ProbeRepository.new - @probe_notification_builder = ProbeNotificationBuilder.new(settings, serializer, telemetry: telemetry) + @probe_notification_builder = ProbeNotificationBuilder.new(settings, serializer, logger, telemetry: telemetry) @probe_notifier_worker = ProbeNotifierWorker.new( settings, logger, agent_settings: agent_settings, diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 905a2120ac..7352c263b2 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -11,17 +11,19 @@ module DI # # @api private class ProbeNotificationBuilder - def initialize(settings, serializer, telemetry: nil) + def initialize(settings, serializer, logger, telemetry:) @settings = settings @serializer = serializer + @logger = logger @telemetry = telemetry @capture_expression_evaluator = CaptureExpressionEvaluator.new( - settings: settings, serializer: serializer, telemetry: telemetry, + settings: settings, serializer: serializer, logger: logger, telemetry: telemetry, ) end attr_reader :settings attr_reader :serializer + attr_reader :logger attr_reader :telemetry attr_reader :capture_expression_evaluator diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs index e1ab395fce..60c2f6c9f5 100644 --- a/sig/datadog/di/capture_expression_evaluator.rbs +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -5,13 +5,15 @@ module Datadog @settings: untyped @serializer: Datadog::DI::Serializer - @telemetry: untyped? + @logger: untyped + @telemetry: untyped - def initialize: (settings: untyped, serializer: Datadog::DI::Serializer, ?telemetry: untyped?) -> void + def initialize: (settings: untyped, serializer: Datadog::DI::Serializer, logger: untyped, telemetry: untyped) -> void attr_reader settings: untyped attr_reader serializer: Datadog::DI::Serializer - attr_reader telemetry: untyped? + attr_reader logger: untyped + attr_reader telemetry: untyped def evaluate: (Probe probe, Context context) -> [Hash[String, untyped], Array[Hash[Symbol, String]]] end diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index 648fd6db5d..6ddaf3ed5d 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -7,14 +7,16 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer - @telemetry: untyped? + @logger: untyped + @telemetry: untyped @capture_expression_evaluator: CaptureExpressionEvaluator - def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, ?telemetry: untyped?) -> void + def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, untyped logger, telemetry: untyped) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Serializer - attr_reader telemetry: untyped? + attr_reader logger: untyped + attr_reader telemetry: untyped attr_reader capture_expression_evaluator: CaptureExpressionEvaluator def build_received: (Probe probe) -> Hash[Symbol,untyped] diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb index a8674766ca..d3748353ed 100644 --- a/spec/datadog/di/capture_expression_evaluator_spec.rb +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -26,8 +26,11 @@ Datadog::DI::Serializer.new(settings, redactor) end + let(:logger) { instance_double(Datadog::Core::Logger).as_null_object } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component).as_null_object } + let(:evaluator) do - described_class.new(settings: settings, serializer: serializer) + described_class.new(settings: settings, serializer: serializer, logger: logger, telemetry: telemetry) end def compile_expression(dsl_string, json) @@ -89,6 +92,17 @@ def compile_expression(dsl_string, json) expect(errors.first[:expr]).to eq("bad_len") expect(errors.first[:message]).to include("ExpressionEvaluationError") end + + it "logs at debug and reports the exception to telemetry" do + expect(logger).to receive(:debug) do |&block| + expect(block.call).to include("bad_len", "evaluation failed", "ExpressionEvaluationError") + end + expect(telemetry).to receive(:report).with( + an_instance_of(Datadog::DI::Error::ExpressionEvaluationError), + description: "DI capture-expression evaluation failed", + ) + evaluator.evaluate(probe, context) + end end context "mixed success and failure" do @@ -141,6 +155,13 @@ def compile_expression(dsl_string, json) expect(output["y"]).to eq(notCapturedReason: "timeout") expect(errors).to eq([]) end + + it "increments the timeout telemetry counter for each timed-out expression" do + expect(telemetry).to receive(:inc).with( + "dynamic_instrumentation", "capture_expression_timeout", 1, + ).twice + evaluator.evaluate(probe, context) + end end context "per-expression depth limit overrides probe-level depth" do diff --git a/spec/datadog/di/instrumenter_circuit_breaker_spec.rb b/spec/datadog/di/instrumenter_circuit_breaker_spec.rb index 6bb4647108..50d092b4b1 100644 --- a/spec/datadog/di/instrumenter_circuit_breaker_spec.rb +++ b/spec/datadog/di/instrumenter_circuit_breaker_spec.rb @@ -350,7 +350,10 @@ def probe_disabled_callback(probe, duration) di_logger_double let(:probe_notification_builder) do - Datadog::DI::ProbeNotificationBuilder.new(settings, serializer) + Datadog::DI::ProbeNotificationBuilder.new( + settings, serializer, logger, + telemetry: instance_double(Datadog::Core::Telemetry::Component).as_null_object, + ) end let(:probe_notifier_worker) do diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index b1776e029c..7bdf67ee5c 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -43,7 +43,10 @@ let(:redactor) { Datadog::DI::Redactor.new(settings) } let(:serializer) { Datadog::DI::Serializer.new(settings, redactor) } - let(:builder) { described_class.new(settings, serializer) } + let(:logger) { instance_double(Datadog::Core::Logger).as_null_object } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component).as_null_object } + + let(:builder) { described_class.new(settings, serializer, logger, telemetry: telemetry) } let(:probe) do Datadog::DI::Probe.new(id: '123', type: :log, file: 'X', line_no: 1) From 951c8b9d6d97b246f4d3069c9a0f18b80457c132 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 12:01:06 -0400 Subject: [PATCH 12/42] DI: document types/contracts of capture-expression public surfaces Add YARD docs to every new public attribute, constructor parameter, and module function added by the capture-expressions PR so a reader can determine the type, shape, and nil-meaning of each value from the source file alone, without opening other files. - capture_expression.rb: @param tags on initialize; attr_reader comments giving type and nil semantics for name/expr/limits. - capture_limits.rb: @param tags on initialize and self.resolve; attr_reader comments naming the integer-or-nil contract per field and what nil means at each level of the resolve chain. - capture_expression_evaluator.rb: attr_reader comments naming the concrete types of settings/serializer/logger/telemetry and how each is consumed at evaluate-time. - probe.rb: @param/@return on snapshot_serializer_limits, naming the resolved Hash{Symbol => Integer} shape (no nils in the output even though probe-level fields may be nil). - probe_builder.rb: full YARD docs for build_capture_expressions and build_capture_limits, including @raise enumeration. - probe_notification_builder.rb: @param tags on initialize; attr_reader comments for the new logger/telemetry/capture_expression_evaluator surfaces. - serializer.rb: @param/@return added to serialize_args and serialize_vars, naming the new length:/collection_size: nil semantics added by this PR. Co-Authored-By: Claude --- lib/datadog/di/capture_expression.rb | 16 ++++++++++ .../di/capture_expression_evaluator.rb | 15 ++++++++++ lib/datadog/di/capture_limits.rb | 29 +++++++++++++++++++ lib/datadog/di/probe.rb | 5 ++++ lib/datadog/di/probe_builder.rb | 19 ++++++++++++ lib/datadog/di/probe_notification_builder.rb | 24 +++++++++++++++ lib/datadog/di/serializer.rb | 21 ++++++++++++++ 7 files changed, 129 insertions(+) diff --git a/lib/datadog/di/capture_expression.rb b/lib/datadog/di/capture_expression.rb index 7e6aca3497..fdbb14cd92 100644 --- a/lib/datadog/di/capture_expression.rb +++ b/lib/datadog/di/capture_expression.rb @@ -16,14 +16,30 @@ module DI # # @api private class CaptureExpression + # @param name [String] user-supplied key under which this expression's + # serialized value appears in the snapshot's captureExpressions block. + # Validated against /\A[a-zA-Z0-9_?]+\z/ by ProbeBuilder. + # @param expr [Datadog::DI::EL::Expression] compiled DSL expression + # evaluated against the probe context at probe-fire time. + # @param limits [Datadog::DI::CaptureLimits, nil] optional per-expression + # capture-limit overrides. nil falls back to probe-level then + # settings-level limits independently per field via CaptureLimits.resolve. def initialize(name:, expr:, limits: nil) @name = name @expr = expr @limits = limits end + # User-supplied snapshot key. See `initialize` for charset constraints. + # @return [String] attr_reader :name + + # Compiled DI expression-language expression evaluated at probe-fire time. + # @return [Datadog::DI::EL::Expression] attr_reader :expr + + # Per-expression capture-limit overrides, or nil when none are configured. + # @return [Datadog::DI::CaptureLimits, nil] attr_reader :limits end end diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 248c91f485..75dd70efc4 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -36,9 +36,24 @@ def initialize(settings:, serializer:, logger:, telemetry:) @telemetry = telemetry end + # Tracer settings; the `dynamic_instrumentation.max_time_to_serialize_ms` + # field is read on every #evaluate call to compute the per-fire deadline. + # @return [Datadog::Core::Configuration::Settings] attr_reader :settings + + # Serializer used to convert evaluated expression values into the + # snapshot wire format. + # @return [Datadog::DI::Serializer] attr_reader :serializer + + # Logger used for debug-level reporting of per-expression evaluation + # failures. + # @return [Datadog::Core::Logger] attr_reader :logger + + # Telemetry component used to report capture-expression timeouts (inc) + # and per-expression evaluation exceptions (report). + # @return [Datadog::Core::Telemetry::Component] attr_reader :telemetry # Evaluate +probe.capture_expressions+ against +context+. diff --git a/lib/datadog/di/capture_limits.rb b/lib/datadog/di/capture_limits.rb index 28854c9a5f..5e3ac1e86e 100644 --- a/lib/datadog/di/capture_limits.rb +++ b/lib/datadog/di/capture_limits.rb @@ -11,6 +11,14 @@ module DI # # @api private class CaptureLimits + # @param max_reference_depth [Integer, nil] override for object traversal + # depth. nil falls back to the next level in the resolve chain. + # @param max_collection_size [Integer, nil] override for Array/Hash size cap. + # nil falls back to the next level in the resolve chain. + # @param max_length [Integer, nil] override for String length cap. + # nil falls back to the next level in the resolve chain. + # @param max_field_count [Integer, nil] override for per-object attribute + # count cap. nil falls back to the next level in the resolve chain. def initialize(max_reference_depth: nil, max_collection_size: nil, max_length: nil, max_field_count: nil) @max_reference_depth = max_reference_depth @@ -19,9 +27,20 @@ def initialize(max_reference_depth: nil, max_collection_size: nil, @max_field_count = max_field_count end + # Object traversal depth override, or nil to defer to probe/settings. + # @return [Integer, nil] attr_reader :max_reference_depth + + # Array/Hash size cap override, or nil to defer to probe/settings. + # @return [Integer, nil] attr_reader :max_collection_size + + # String length cap override, or nil to defer to probe/settings. + # @return [Integer, nil] attr_reader :max_length + + # Per-object attribute count cap override, or nil to defer to probe/settings. + # @return [Integer, nil] attr_reader :max_field_count # Resolves effective limits for a capture expression by walking @@ -35,6 +54,16 @@ def initialize(max_reference_depth: nil, max_collection_size: nil, # # Returns a Hash with keys :depth, :collection_size, :length, # :attribute_count to match the serializer's keyword arguments. + # + # @param expr_limits [Datadog::DI::CaptureLimits, nil] per-expression + # overrides, or nil to skip the expression level entirely. + # @param probe [Datadog::DI::Probe] probe carrying probe-level overrides + # (max_capture_depth, max_capture_collection_size, max_capture_string_length, + # max_capture_attribute_count). Any field may be nil. + # @param settings [Datadog::Core::Configuration::Settings] tracer settings + # providing the dynamic_instrumentation.max_capture_* fallback values. + # @return [Hash{Symbol => Integer}] fully resolved limits with keys + # :depth, :collection_size, :length, :attribute_count. def self.resolve(expr_limits:, probe:, settings:) di = settings.dynamic_instrumentation { diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index f87c76e208..81156488e8 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -176,6 +176,11 @@ def capture_expressions? # falling back to the DI settings default when the probe-level value # is nil. The result is intended to be splatted into # +Serializer#serialize_value+, +serialize_args+, or +serialize_vars+. + # + # @param settings [Datadog::Core::Configuration::Settings] tracer settings + # providing the dynamic_instrumentation.max_capture_* fallback values. + # @return [Hash{Symbol => Integer}] hash with keys :depth, :attribute_count, + # :length, :collection_size — all values are Integers (no nils). def snapshot_serializer_limits(settings) di = settings.dynamic_instrumentation { diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 8fc329d53f..6f1931960a 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -82,6 +82,17 @@ def build_from_remote_config(config) raise ArgumentError, "Malformed remote configuration entry for probe: #{exc.class}: #{exc.message}: #{config}" end + # Parses the `captureExpressions` block of a remote-config probe payload + # into compiled CaptureExpression instances. + # + # @param raw [Array, nil] raw `captureExpressions` value from the + # remote-config payload. nil is treated as "no expressions" (returns []). + # @return [Array] one per entry; empty + # array when raw is nil or empty. + # @raise [ArgumentError] when raw is non-nil and not an Array, when any + # entry is not a Hash, when an entry's name is missing or fails the + # CAPTURE_EXPRESSION_NAME_PATTERN charset check, or when the `expr` + # field is missing/malformed. def build_capture_expressions(raw) return [] if raw.nil? unless Array === raw @@ -107,6 +118,14 @@ def build_capture_expressions(raw) end end + # Parses a per-expression `capture` block of a remote-config probe payload + # into a CaptureLimits instance. + # + # @param raw [Hash, nil] raw per-expression `capture` value. nil means + # "no per-expression overrides" (returns nil). + # @return [Datadog::DI::CaptureLimits, nil] CaptureLimits when raw is a + # Hash; nil when raw is nil. + # @raise [ArgumentError] when raw is non-nil and not a Hash. def build_capture_limits(raw) return nil if raw.nil? unless Hash === raw diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 7352c263b2..77c4767905 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -11,6 +11,15 @@ module DI # # @api private class ProbeNotificationBuilder + # @param settings [Datadog::Core::Configuration::Settings] tracer settings; + # read at payload-build time for service/env/version/tags fields. + # @param serializer [Datadog::DI::Serializer] serializer used for snapshot + # values (captures, return, throwable). + # @param logger [Datadog::Core::Logger] logger forwarded to the internal + # CaptureExpressionEvaluator for per-expression evaluation failures. + # @param telemetry [Datadog::Core::Telemetry::Component] telemetry forwarded + # to the internal CaptureExpressionEvaluator for timeout / failure + # reporting. def initialize(settings, serializer, logger, telemetry:) @settings = settings @serializer = serializer @@ -21,10 +30,25 @@ def initialize(settings, serializer, logger, telemetry:) ) end + # Tracer settings; read at every build_* call. + # @return [Datadog::Core::Configuration::Settings] attr_reader :settings + + # Serializer used to convert captured values into snapshot wire format. + # @return [Datadog::DI::Serializer] attr_reader :serializer + + # Logger; passed through to CaptureExpressionEvaluator at construction. + # @return [Datadog::Core::Logger] attr_reader :logger + + # Telemetry; passed through to CaptureExpressionEvaluator at construction. + # @return [Datadog::Core::Telemetry::Component] attr_reader :telemetry + + # Lazily-constructed sub-component that evaluates probe.capture_expressions + # during build_snapshot when probe.capture_expressions? is true. + # @return [Datadog::DI::CaptureExpressionEvaluator] attr_reader :capture_expression_evaluator def build_received(probe) diff --git a/lib/datadog/di/serializer.rb b/lib/datadog/di/serializer.rb index b0bf4b9372..e60c8f80ea 100644 --- a/lib/datadog/di/serializer.rb +++ b/lib/datadog/di/serializer.rb @@ -129,6 +129,18 @@ def combine_args(args, kwargs, target_self) # Instance variables are technically a hash just like kwargs, # we take them as a separate parameter to avoid a hash merge # in upstream code. + # + # @param args [Array] positional arguments captured at the call site. + # @param kwargs [Hash] keyword arguments captured at the call site. + # @param target_self [Object] the receiver of the instrumented method + # call; its instance variables are folded into the output. + # @param depth [Integer] object traversal depth cap. + # @param attribute_count [Integer] per-object attribute count cap. + # @param length [Integer, nil] String length cap; nil leaves it + # unspecified and lets serialize_value fall back to settings. + # @param collection_size [Integer, nil] Array/Hash size cap; nil leaves + # it unspecified and lets serialize_value fall back to settings. + # @return [Hash{Symbol => Hash}] serialized argument map. def serialize_args(args, kwargs, target_self, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, @@ -143,6 +155,15 @@ def serialize_args(args, kwargs, target_self, # # These are normally local variables that exist on a particular line # of executed code. + # + # @param vars [Hash{Symbol => Object}] variable-name => value map. + # @param depth [Integer] object traversal depth cap. + # @param attribute_count [Integer] per-object attribute count cap. + # @param length [Integer, nil] String length cap; nil leaves it + # unspecified and lets serialize_value fall back to settings. + # @param collection_size [Integer, nil] Array/Hash size cap; nil leaves + # it unspecified and lets serialize_value fall back to settings. + # @return [Hash{Symbol => Hash}] serialized variable map. def serialize_vars(vars, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, From c177f2865f8de266fb8b1a22c45efd99a1688eb5 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 12:13:04 -0400 Subject: [PATCH 13/42] DI: remove references to internal planning repo from public source Code comments in the capture-expressions PR referenced design docs in an internal planning repo (paths like projects/capture-expressions/design/decisions.md) and used bare decision identifiers (D1, D3, D4, D5) that only resolve inside that planning repo. The references would 404 for any DataDog/dd-trace-rb reader. For each occurrence, either the surrounding text already conveyed the intent (drop the "see " sentence) or the cross-tracer comparison was sourced inline (Python/Java/Go DI) so the reference was redundant. No information loss for someone reading only the public source. Files updated: - capture_expression_evaluator.rb class docstring (two paths + "see D4") - probe.rb rate_limit comment ("planning repo" sentence) - probe_builder.rb mutual-exclusion comment (one path + "(D1)") - probe_notification_builder.rb mutual-exclusion comment (one path + "(D1)") - probe_notification_builder.rb evaluation-errors merge comment (one path) Co-Authored-By: Claude --- lib/datadog/di/capture_expression_evaluator.rb | 8 +++----- lib/datadog/di/probe.rb | 2 -- lib/datadog/di/probe_builder.rb | 10 +++++----- lib/datadog/di/probe_notification_builder.rb | 14 +++++++------- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 75dd70efc4..ca8d689d3e 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -13,13 +13,11 @@ module DI # key is omitted from the output and an `{ expr: name, message: ... }` # entry is appended to the returned `evaluation_errors` array. This # matches the cross-tracer convention (Python, Java, .NET, Node.js, - # Go DI) — see - # projects/capture-expressions/specs/wire-format.md §8.8 and - # projects/capture-expressions/design/decisions.md (D5). + # Go DI). # # On per-fire time-budget exhaustion, remaining expressions emit a - # stub `{ "notCapturedReason" => "timeout" }` entry (Node.js shape; - # see D4). + # stub `{ "notCapturedReason" => "timeout" }` entry, matching the + # Node.js DI shape. # # @api private class CaptureExpressionEvaluator diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index 81156488e8..d19fc06c9b 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -93,8 +93,6 @@ def initialize(id:, type:, # bucket (1/sec default), matching Python/.NET/Go DI. They are not # treated as cheap log probes (5000/sec) because evaluating arbitrary # user-authored expressions has snapshot-class cost. - # See projects/capture-expressions/design/decisions.md (D3) in the - # planning repo for the cross-tracer comparison. @rate_limit = rate_limit || ((@capture_snapshot || !@capture_expressions.empty?) ? 1 : 5000) @rate_limiter = Datadog::Core::TokenBucket.new(@rate_limit) diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 6f1931960a..e67ea5802a 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -47,11 +47,11 @@ def build_from_remote_config(config) end capture_expressions = build_capture_expressions(config['captureExpressions']) if !!config["captureSnapshot"] && !capture_expressions.empty? - # Per the project design, the runtime emits the snapshot block and - # silently drops capture-expression values when both are set - # (snapshot wins). Logged at debug to make the choice observable - # without spamming operator logs. See - # projects/capture-expressions/design/decisions.md (D1). + # When both captureSnapshot and captureExpressions are set on the + # same probe, the runtime emits the snapshot block and silently + # drops capture-expression values (snapshot wins), matching + # Python/Java/Go DI. Logged at debug to make the choice observable + # without spamming operator logs. Datadog.logger.debug do "di: probe #{config["id"]}: captureSnapshot=true wins over captureExpressions (n=#{capture_expressions.size})" end diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 77c4767905..efab5a56ea 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -98,11 +98,11 @@ def build_snapshot(context) raise ArgumentError, "Asked to build snapshot with snapshot capture but target_self is nil" end - # Mutual exclusion (D1): capture_snapshot wins at fire time when both - # captureSnapshot and captureExpressions are set on the same probe. - # The capture-expression values are silently dropped in that case; - # the user-visible mutual exclusion is also logged at parse time in - # ProbeBuilder. See projects/capture-expressions/design/decisions.md. + # Mutual exclusion: capture_snapshot wins at fire time when both + # captureSnapshot and captureExpressions are set on the same probe, + # matching Python/Java/Go DI. The capture-expression values are + # silently dropped in that case; the user-visible mutual exclusion + # is also logged at parse time in ProbeBuilder. # TODO also verify that non-capturing probe does not pass # snapshot or vars/args into this method capture_expression_evaluation_errors = [] @@ -158,8 +158,8 @@ def build_snapshot(context) message, evaluation_errors = evaluate_template(segments, context) end # Per-expression evaluation errors are merged into the snapshot's - # top-level evaluationErrors array alongside template/condition errors. - # See projects/capture-expressions/design/decisions.md (D5). + # top-level evaluationErrors array alongside template/condition errors, + # matching the cross-tracer convention. evaluation_errors.concat(capture_expression_evaluation_errors) build_snapshot_base(context, evaluation_errors: evaluation_errors, message: message, From d98276baffaeabdc0f9cf057f8761da1e161f4df Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 12:15:29 -0400 Subject: [PATCH 14/42] DI: tighten RBS sigs and fix trailing blank line in capture_expression_spec Replace `untyped` placeholders in the capture-expressions RBS sigs with the concrete types already established in the rest of DI: - settings: Datadog::Core::Configuration::Settings (was untyped on CaptureExpressionEvaluator, CaptureLimits.resolve, Probe#snapshot_serializer_limits) - logger: DI::Logger (was untyped on CaptureExpressionEvaluator, ProbeNotificationBuilder; Component#initialize wraps the Core::Logger into DI::Logger before passing it down, matching Instrumenter / ProbeManager sigs) - telemetry: Core::Telemetry::Component (was untyped on CaptureExpressionEvaluator and ProbeNotificationBuilder; the earlier fix commit already made the constructors non-nil-required, so the sigs drop the trailing `?`) Steep still passes on lib/datadog/di/ after the tightening. Also remove a trailing blank line in spec/datadog/di/capture_expression_spec.rb flagged by Layout/TrailingEmptyLines on CI. Co-Authored-By: Claude --- sig/datadog/di/capture_expression_evaluator.rbs | 14 +++++++------- sig/datadog/di/capture_limits.rbs | 2 +- sig/datadog/di/probe.rbs | 2 +- sig/datadog/di/probe_notification_builder.rbs | 10 +++++----- spec/datadog/di/capture_expression_spec.rb | 1 - 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs index 60c2f6c9f5..8b6b782e20 100644 --- a/sig/datadog/di/capture_expression_evaluator.rbs +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -3,17 +3,17 @@ module Datadog class CaptureExpressionEvaluator TELEMETRY_NAMESPACE: String - @settings: untyped + @settings: Datadog::Core::Configuration::Settings @serializer: Datadog::DI::Serializer - @logger: untyped - @telemetry: untyped + @logger: DI::Logger + @telemetry: Core::Telemetry::Component - def initialize: (settings: untyped, serializer: Datadog::DI::Serializer, logger: untyped, telemetry: untyped) -> void + def initialize: (settings: Datadog::Core::Configuration::Settings, serializer: Datadog::DI::Serializer, logger: DI::Logger, telemetry: Core::Telemetry::Component) -> void - attr_reader settings: untyped + attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Datadog::DI::Serializer - attr_reader logger: untyped - attr_reader telemetry: untyped + attr_reader logger: DI::Logger + attr_reader telemetry: Core::Telemetry::Component def evaluate: (Probe probe, Context context) -> [Hash[String, untyped], Array[Hash[Symbol, String]]] end diff --git a/sig/datadog/di/capture_limits.rbs b/sig/datadog/di/capture_limits.rbs index a3f2d829fc..83dca31c2d 100644 --- a/sig/datadog/di/capture_limits.rbs +++ b/sig/datadog/di/capture_limits.rbs @@ -13,7 +13,7 @@ module Datadog attr_reader max_length: Integer? attr_reader max_field_count: Integer? - def self.resolve: (expr_limits: CaptureLimits?, probe: Probe, settings: untyped) -> Hash[Symbol, Integer] + def self.resolve: (expr_limits: CaptureLimits?, probe: Probe, settings: Datadog::Core::Configuration::Settings) -> Hash[Symbol, Integer] end end end diff --git a/sig/datadog/di/probe.rbs b/sig/datadog/di/probe.rbs index 1fd3d403f0..b0f23b5021 100644 --- a/sig/datadog/di/probe.rbs +++ b/sig/datadog/di/probe.rbs @@ -69,7 +69,7 @@ module Datadog def capture_snapshot?: () -> bool def capture_expressions?: () -> bool - def snapshot_serializer_limits: (untyped settings) -> Hash[Symbol, Integer] + def snapshot_serializer_limits: (Datadog::Core::Configuration::Settings settings) -> Hash[Symbol, Integer] def line?: () -> bool def method?: () -> bool def line_no!: () -> Integer diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index 6ddaf3ed5d..64ad3724a2 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -7,16 +7,16 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer - @logger: untyped - @telemetry: untyped + @logger: DI::Logger + @telemetry: Core::Telemetry::Component @capture_expression_evaluator: CaptureExpressionEvaluator - def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, untyped logger, telemetry: untyped) -> void + def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, DI::Logger logger, telemetry: Core::Telemetry::Component) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Serializer - attr_reader logger: untyped - attr_reader telemetry: untyped + attr_reader logger: DI::Logger + attr_reader telemetry: Core::Telemetry::Component attr_reader capture_expression_evaluator: CaptureExpressionEvaluator def build_received: (Probe probe) -> Hash[Symbol,untyped] diff --git a/spec/datadog/di/capture_expression_spec.rb b/spec/datadog/di/capture_expression_spec.rb index ec0bc2ad3c..c52c2a686e 100644 --- a/spec/datadog/di/capture_expression_spec.rb +++ b/spec/datadog/di/capture_expression_spec.rb @@ -18,4 +18,3 @@ end end end - From 03df970b0c0a92eee9b7c845ce916c16c596e066 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 12:16:43 -0400 Subject: [PATCH 15/42] DI: update integration spec to match new ProbeNotificationBuilder signature The earlier commit that made `logger` a positional arg and `telemetry:` a required kwarg on ProbeNotificationBuilder updated the unit spec but missed this integration spec, which uses `described_class.new` rather than `ProbeNotificationBuilder.new` (so the earlier grep didn't catch it). All 6 examples were failing with: ArgumentError: wrong number of arguments (given 2, expected 3; required keyword: telemetry) Adds logger/telemetry doubles via the same as_null_object pattern used in the unit spec. Co-Authored-By: Claude --- .../di/integration/probe_notification_builder_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/datadog/di/integration/probe_notification_builder_spec.rb b/spec/datadog/di/integration/probe_notification_builder_spec.rb index 29b79996d0..c0c7c11ce4 100644 --- a/spec/datadog/di/integration/probe_notification_builder_spec.rb +++ b/spec/datadog/di/integration/probe_notification_builder_spec.rb @@ -10,7 +10,10 @@ let(:redactor) { Datadog::DI::Redactor.new(settings) } let(:serializer) { Datadog::DI::Serializer.new(settings, redactor) } - let(:builder) { described_class.new(settings, serializer) } + let(:logger) { instance_double(Datadog::Core::Logger).as_null_object } + let(:telemetry) { instance_double(Datadog::Core::Telemetry::Component).as_null_object } + + let(:builder) { described_class.new(settings, serializer, logger, telemetry: telemetry) } let(:instrumenter) do Datadog::DI::Instrumenter.new(settings) From 15a411f760332011ebc1bf125d59f178e52ac8fe Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 3 Jun 2026 18:40:26 -0400 Subject: [PATCH 16/42] DI: add end-to-end tests for capture-expression behaviors, restore optional telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five integration tests covering the customer-observable behaviors the PR claims, which were previously only verified at unit level: 1. method probe mutual exclusion — both captureSnapshot=true and captureExpressions set on the probe; asserts the snapshot carries the `arguments` block and `captureExpressions` is absent from both entry and return blocks. 2. method probe timeout — sets max_time_to_serialize_ms=0; asserts captureExpressions = {"arg1" => {notCapturedReason: "timeout"}} on both entry and return. 3. method probe with raising expression — uses len(undefined) which raises ExpressionEvaluationError; asserts the key is omitted from captureExpressions and an entry appears in the snapshot's top-level evaluationErrors array. 4. line probe with capture expression — uses the existing line-probe integration test class; asserts captures.lines[40].captureExpressions contains the resolved local variable value. Writing these tests surfaced a regression from the earlier "make telemetry required" commit: Component.build(...) legitimately accepts a nil telemetry kwarg and threads nil through to ProbeNotificationBuilder, which then constructed CaptureExpressionEvaluator with telemetry: nil. The earlier fix removed the &. guards on the two telemetry call sites (timeout inc, rescue report), causing NoMethodError in the integration tests where no telemetry component is configured. Restores telemetry as a truly-optional dependency on both CaptureExpressionEvaluator and ProbeNotificationBuilder: - `telemetry: nil` default - `telemetry&.inc(...)` / `telemetry&.report(...)` at the call sites - RBS sigs `Core::Telemetry::Component?` (nullable) - attr_reader doc comments name the nil case and how it is handled The unit specs (which pass `instance_double(...).as_null_object`) are unaffected — &. on a non-nil null-object still dispatches. Co-Authored-By: Claude --- .../di/capture_expression_evaluator.rb | 16 +- lib/datadog/di/probe_notification_builder.rb | 11 +- .../di/capture_expression_evaluator.rbs | 6 +- sig/datadog/di/probe_notification_builder.rbs | 6 +- .../di/integration/instrumentation_spec.rb | 159 ++++++++++++++++++ 5 files changed, 181 insertions(+), 17 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index ca8d689d3e..bf98cc65a5 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -26,8 +26,11 @@ class CaptureExpressionEvaluator # @param settings [Datadog::Core::Configuration::Settings] # @param serializer [Datadog::DI::Serializer] # @param logger [Datadog::Core::Logger] - # @param telemetry [Datadog::Core::Telemetry::Component] - def initialize(settings:, serializer:, logger:, telemetry:) + # @param telemetry [Datadog::Core::Telemetry::Component, nil] nil is + # legitimate — Component.build accepts nil telemetry when DI is used + # outside a telemetry-configured environment, and Component threads + # the nil through to here. All telemetry calls below are nil-guarded. + def initialize(settings:, serializer:, logger:, telemetry: nil) @settings = settings @serializer = serializer @logger = logger @@ -50,8 +53,9 @@ def initialize(settings:, serializer:, logger:, telemetry:) attr_reader :logger # Telemetry component used to report capture-expression timeouts (inc) - # and per-expression evaluation exceptions (report). - # @return [Datadog::Core::Telemetry::Component] + # and per-expression evaluation exceptions (report). nil when DI was + # constructed without telemetry; all call sites guard with `&.`. + # @return [Datadog::Core::Telemetry::Component, nil] attr_reader :telemetry # Evaluate +probe.capture_expressions+ against +context+. @@ -73,7 +77,7 @@ def evaluate(probe, context) if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns output[name] = {notCapturedReason: "timeout"} - telemetry.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) + telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) next end @@ -96,7 +100,7 @@ def evaluate(probe, context) logger.debug do "di: probe #{probe.id}: capture expression #{name}: evaluation failed: #{exc.class}: #{exc.message}" end - telemetry.report(exc, description: "DI capture-expression evaluation failed") + telemetry&.report(exc, description: "DI capture-expression evaluation failed") end end diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index efab5a56ea..8fece38bdd 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -17,10 +17,10 @@ class ProbeNotificationBuilder # values (captures, return, throwable). # @param logger [Datadog::Core::Logger] logger forwarded to the internal # CaptureExpressionEvaluator for per-expression evaluation failures. - # @param telemetry [Datadog::Core::Telemetry::Component] telemetry forwarded - # to the internal CaptureExpressionEvaluator for timeout / failure - # reporting. - def initialize(settings, serializer, logger, telemetry:) + # @param telemetry [Datadog::Core::Telemetry::Component, nil] telemetry + # forwarded to the internal CaptureExpressionEvaluator. nil when DI was + # constructed without telemetry (Component.build allows this). + def initialize(settings, serializer, logger, telemetry: nil) @settings = settings @serializer = serializer @logger = logger @@ -43,7 +43,8 @@ def initialize(settings, serializer, logger, telemetry:) attr_reader :logger # Telemetry; passed through to CaptureExpressionEvaluator at construction. - # @return [Datadog::Core::Telemetry::Component] + # nil when DI was constructed without telemetry. + # @return [Datadog::Core::Telemetry::Component, nil] attr_reader :telemetry # Lazily-constructed sub-component that evaluates probe.capture_expressions diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs index 8b6b782e20..175eb438c6 100644 --- a/sig/datadog/di/capture_expression_evaluator.rbs +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -6,14 +6,14 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Datadog::DI::Serializer @logger: DI::Logger - @telemetry: Core::Telemetry::Component + @telemetry: Core::Telemetry::Component? - def initialize: (settings: Datadog::Core::Configuration::Settings, serializer: Datadog::DI::Serializer, logger: DI::Logger, telemetry: Core::Telemetry::Component) -> void + def initialize: (settings: Datadog::Core::Configuration::Settings, serializer: Datadog::DI::Serializer, logger: DI::Logger, ?telemetry: Core::Telemetry::Component?) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Datadog::DI::Serializer attr_reader logger: DI::Logger - attr_reader telemetry: Core::Telemetry::Component + attr_reader telemetry: Core::Telemetry::Component? def evaluate: (Probe probe, Context context) -> [Hash[String, untyped], Array[Hash[Symbol, String]]] end diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index 64ad3724a2..d0aa5d39bb 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -8,15 +8,15 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer @logger: DI::Logger - @telemetry: Core::Telemetry::Component + @telemetry: Core::Telemetry::Component? @capture_expression_evaluator: CaptureExpressionEvaluator - def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, DI::Logger logger, telemetry: Core::Telemetry::Component) -> void + def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, DI::Logger logger, ?telemetry: Core::Telemetry::Component?) -> void attr_reader settings: Datadog::Core::Configuration::Settings attr_reader serializer: Serializer attr_reader logger: DI::Logger - attr_reader telemetry: Core::Telemetry::Component + attr_reader telemetry: Core::Telemetry::Component? attr_reader capture_expression_evaluator: CaptureExpressionEvaluator def build_received: (Probe probe) -> Hash[Symbol,untyped] diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 36f9766965..019463d0ce 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -597,6 +597,128 @@ def run_test end end + context 'with both captureSnapshot=true and capture_expressions (mutual exclusion)' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'test_method'}, + captureSnapshot: true, + captureExpressions: [ + {name: 'arg1', expr: {dsl: 'arg1', json: {ref: 'arg1'}}}, + ], + } + end + + it 'emits the snapshot captures block and omits captureExpressions' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationSpecTestClass.new.test_method(7)).to eq(42) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + expect(captures.fetch(:entry)).to have_key(:arguments) + expect(captures.fetch(:entry)).not_to have_key(:captureExpressions) + expect(captures.fetch(:return)).to have_key(:arguments) + expect(captures.fetch(:return)).not_to have_key(:captureExpressions) + end + end + + context 'with capture expressions and an exhausted per-fire time budget' do + before do + settings.dynamic_instrumentation.max_time_to_serialize_ms = 0 + end + + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'test_method'}, + captureExpressions: [ + {name: 'arg1', expr: {dsl: 'arg1', json: {ref: 'arg1'}}}, + ], + } + end + + it 'emits notCapturedReason:timeout stubs for the expressions in both entry and return blocks' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationSpecTestClass.new.test_method(7)).to eq(42) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( + "arg1" => {notCapturedReason: "timeout"}, + ) + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "arg1" => {notCapturedReason: "timeout"}, + ) + end + end + + context 'with a capture expression that raises during evaluation' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + # len(undefined) — `undefined` resolves to nil at the EL layer, and + # len(nil) raises ExpressionEvaluationError, which the evaluator + # catches and reports via evaluationErrors. + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'test_method'}, + captureExpressions: [ + {name: 'bad_len', expr: {dsl: 'len(undefined)', json: {len: {ref: 'undefined'}}}}, + ], + } + end + + it 'omits the failed key from captureExpressions and appends to evaluationErrors' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationSpecTestClass.new.test_method(7)).to eq(42) + component.probe_notifier_worker.flush + + snapshot = payload.fetch(:debugger).fetch(:snapshot) + captures = snapshot.fetch(:captures) + expect(captures.fetch(:return).fetch(:captureExpressions)).to eq({}) + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq({}) + + evaluation_errors = snapshot.fetch(:evaluationErrors) + # Both entry and return paths run the expression — each surfaces + # its own error entry, so we expect at least one with this name + # and message shape. + matching = evaluation_errors.select { |e| e[:expr] == 'bad_len' } + expect(matching).not_to be_empty + expect(matching.first[:message]).to include('ExpressionEvaluationError') + end + end + context 'with snapshot capture and probe-level maxLength override' do let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, @@ -962,6 +1084,43 @@ def run_test include_examples 'simple log probe' + context 'with capture expression referencing a local variable' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + # Line 40 of the test target reads `a * 2` after `a = 21` on line 33. + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {sourceFile: 'instrumentation_integration_test_class.rb', lines: [40]}, + captureExpressions: [ + {name: 'a', expr: {dsl: 'a', json: {ref: 'a'}}}, + ], + } + end + + it 'emits captureExpressions on the snapshot line block' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationIntegrationTestClass.new.test_method).to eq(42) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + lines = captures.fetch(:lines) + expect(lines.keys).to eq([40]) + expect(lines[40].fetch(:captureExpressions)).to eq( + "a" => {type: 'Integer', value: '21'}, + ) + end + end + context 'target line is the end line of a method' do let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, From 1bcc71898979d97710ebc762664c97a660e07549 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 9 Jun 2026 10:05:56 -0400 Subject: [PATCH 17/42] DI: declare max_time_to_serialize_ms in _DI settings RBS interface The capture_expression_evaluator reads settings.dynamic_instrumentation.max_time_to_serialize_ms but the RBS interface for the dynamic_instrumentation namespace did not declare it, so steep flagged it as Ruby::NoMethod on lib/datadog/di/capture_expression_evaluator.rb:69. The option is defined in lib/datadog/di/configuration.rb as type :int with default 200, matching the Integer signature used for the other max_* options in the interface. Verified locally: bundle exec steep check lib/datadog/di/capture_expression_evaluator.rb reports no errors. --- sig/datadog/core/configuration/settings.rbs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sig/datadog/core/configuration/settings.rbs b/sig/datadog/core/configuration/settings.rbs index 4cd17f6b48..0e7323f02c 100644 --- a/sig/datadog/core/configuration/settings.rbs +++ b/sig/datadog/core/configuration/settings.rbs @@ -131,6 +131,10 @@ module Datadog def max_capture_attribute_count=: (Integer) -> void + def max_time_to_serialize_ms: () -> Integer + + def max_time_to_serialize_ms=: (Integer) -> void + def internal: () -> _DIInternal end From a223e5cae6b69aacb2d3fe095f27b328b21caa61 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 9 Jun 2026 11:22:55 -0400 Subject: [PATCH 18/42] DI: drop entry.captureExpressions from method probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture expressions are evaluated once, at method return — the only point where args, kwargs, self, @return, and exception are all observable. The previous code emitted the return-time captured_block under BOTH the entry and return keys of the snapshot's captures map. The snapshot schema's entry/return split exists for captureSnapshot, which freezes args before super (entry) and reads self/@return after (return). For captureExpressions there is no entry-time evaluation, so putting the return-time hash under the entry key mislabeled exit-time data as entry-time state. A customer expression like `arg1` on a method that mutates its argument would appear under entry with the post-mutation value — directly contradicting what entry means under captureSnapshot. Stop emitting the entry block for method-probe captureExpressions. Line-probe captureExpressions are unchanged (single block, no entry/return distinction). captureSnapshot is unchanged. Tests updated to assert the entry key is absent for method probes with capture_expressions only. Verified locally: bundle exec rspec spec/datadog/di/probe_notification_builder_spec.rb bundle exec rspec spec/datadog/di/integration/instrumentation_spec.rb bundle exec steep check lib/datadog/di/probe_notification_builder.rb --- lib/datadog/di/probe_notification_builder.rb | 9 ++++++++- .../di/integration/instrumentation_spec.rb | 16 +++++----------- .../di/probe_notification_builder_spec.rb | 6 +++--- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 8fece38bdd..cf5022cf71 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -137,8 +137,15 @@ def build_snapshot(context) captured_block, capture_expression_evaluation_errors = capture_expression_evaluator.evaluate(probe, context) if probe.method? + # Capture expressions are evaluated once, at method return — the + # only point where args, kwargs, self, @return, and exception are + # all observable. The snapshot schema's entry/return split exists + # for captureSnapshot, which freezes args before super and reads + # self/@return after. captureExpressions has no entry-time + # evaluation, so we do not emit an entry block — populating it + # with the return-time captured_block would mislabel exit-time + # data as entry-time state. { - entry: {captureExpressions: captured_block}, return: { captureExpressions: captured_block, throwable: context.exception ? serialize_throwable(context.exception) : nil, diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 019463d0ce..1f811e045c 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -591,9 +591,8 @@ def run_test expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( "arg1" => {type: 'Integer', value: '7'}, ) - expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( - "arg1" => {type: 'Integer', value: '7'}, - ) + # Capture expressions are evaluated once at return; no entry block is emitted. + expect(captures).not_to have_key(:entry) end end @@ -653,7 +652,7 @@ def run_test } end - it 'emits notCapturedReason:timeout stubs for the expressions in both entry and return blocks' do + it 'emits notCapturedReason:timeout stubs in the return block' do expect(diagnostics_transport).to receive(:send_diagnostics) probe_manager.add_probe(probe) payload = nil @@ -668,9 +667,7 @@ def run_test expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( "arg1" => {notCapturedReason: "timeout"}, ) - expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( - "arg1" => {notCapturedReason: "timeout"}, - ) + expect(captures).not_to have_key(:entry) end end @@ -707,12 +704,9 @@ def run_test snapshot = payload.fetch(:debugger).fetch(:snapshot) captures = snapshot.fetch(:captures) expect(captures.fetch(:return).fetch(:captureExpressions)).to eq({}) - expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq({}) + expect(captures).not_to have_key(:entry) evaluation_errors = snapshot.fetch(:evaluationErrors) - # Both entry and return paths run the expression — each surfaces - # its own error entry, so we expect at least one with this name - # and message shape. matching = evaluation_errors.select { |e| e[:expr] == 'bad_len' } expect(matching).not_to be_empty expect(matching.first[:message]).to include('ExpressionEvaluationError') diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index 7bdf67ee5c..a87aa663f0 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -756,12 +756,12 @@ capture_expressions: [capture_expression]) end - it 'emits captureExpressions under both entry and return blocks' do + it 'emits captureExpressions in the return block and omits the entry block' do payload = builder.build_snapshot(context) captures = payload[:debugger][:snapshot][:captures] - expect(captures[:entry][:captureExpressions].keys).to eq(["x"]) + expect(captures).not_to have_key(:entry) expect(captures[:return][:captureExpressions].keys).to eq(["x"]) - expect(captures[:entry][:captureExpressions]["x"]).to include(type: "Integer", value: "42") + expect(captures[:return][:captureExpressions]["x"]).to include(type: "Integer", value: "42") end end From 25fbe0c25140d101125606a1f7f17ff2e8bd71f7 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 9 Jun 2026 11:29:43 -0400 Subject: [PATCH 19/42] DI: rename capture-expression timeout counter, add mid-loop test, guard combine_args Three follow-ups from the capture-expressions review: 1. Telemetry counter renamed from `capture_expression_timeout` to `capture_expressions_skipped_by_timeout`. The metric is per-expression (a single probe fire with N timed-out expressions increments by N). The new name reflects the unit. Comment added at the increment site. 2. New test in capture_expression_evaluator_spec exercising the realistic mid-loop case: one expression evaluates successfully, then the deadline check at the top of the next iteration fires. The existing "exhausted before evaluating any" context (budget = 0) only covered the trivial degenerate case. The new test stubs Process.clock_gettime with and_wrap_original so non-:nanosecond callers (Time.now via :float_second) still hit the real impl. 3. Skip serializer.combine_args in the method-probe return path when captureSnapshot is also set on the probe. Mutual exclusion means the combined hash is discarded at build time; the call was wasted work. The guard now mirrors the build-time precedence. Verified locally: bundle exec rspec spec/datadog/di/capture_expression_evaluator_spec.rb bundle exec rspec spec/datadog/di/integration/instrumentation_spec.rb bundle exec rspec spec/datadog/di/probe_notification_builder_spec.rb bundle exec steep check lib/datadog/di/instrumenter.rb lib/datadog/di/capture_expression_evaluator.rb All green. --- .../di/capture_expression_evaluator.rb | 5 +- lib/datadog/di/instrumenter.rb | 4 +- .../di/capture_expression_evaluator_spec.rb | 51 ++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index bf98cc65a5..4a5aac6b47 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -77,7 +77,10 @@ def evaluate(probe, context) if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns output[name] = {notCapturedReason: "timeout"} - telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expression_timeout", 1) + # Counter is per-expression: a single probe fire with N expressions + # that all time out increments by N. The metric name reflects the + # unit ("expressions skipped"), not "fires timed out". + telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expressions_skipped_by_timeout", 1) next end diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index d4922ae9e9..dc802fb23b 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -274,7 +274,9 @@ def hook_method(probe, responder) # arg references when the probe has capture expressions; values # may reflect in-place mutation by the method body, matching the # return-time scope semantics. - capture_expression_locals = if probe.capture_expressions? + # Skip when capture_snapshot is also set — the snapshot wins at + # build time, so the combined hash would be discarded. + capture_expression_locals = if probe.capture_expressions? && !probe.capture_snapshot? serializer.combine_args(args, kwargs, self) end context = Context.new(locals: capture_expression_locals, target_self: self, diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb index d3748353ed..f0d856b0a5 100644 --- a/spec/datadog/di/capture_expression_evaluator_spec.rb +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -158,12 +158,61 @@ def compile_expression(dsl_string, json) it "increments the timeout telemetry counter for each timed-out expression" do expect(telemetry).to receive(:inc).with( - "dynamic_instrumentation", "capture_expression_timeout", 1, + "dynamic_instrumentation", "capture_expressions_skipped_by_timeout", 1, ).twice evaluator.evaluate(probe, context) end end + context "time budget exhausted mid-loop after some expressions have evaluated" do + before do + allow(di_settings).to receive(:max_time_to_serialize_ms).and_return(100) + # Stub the clock so the deadline calculation sees t=0, the first + # iteration's check sees t=0 (under deadline of 100ms in ns), and + # the second iteration's check sees t=200ms in ns (past deadline). + # Targets only the :nanosecond variant the evaluator uses; other + # callers (Time.now via :float_second, etc.) fall through to the + # real implementation. + clock_calls = 0 + clock_returns = [0, 0, 200_000_000] + allow(::Process).to receive(:clock_gettime).and_wrap_original do |original, *args| + if args == [::Process::CLOCK_MONOTONIC, :nanosecond] + clock_returns[clock_calls].tap { clock_calls += 1 } + else + original.call(*args) + end + end + end + + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new( + name: "x", expr: compile_expression("x", {"ref" => "x"}), + ), + Datadog::DI::CaptureExpression.new( + name: "y", expr: compile_expression("name", {"ref" => "name"}), + ), + ], + ) + end + + it "evaluates the first expression and times out the second" do + output, errors = evaluator.evaluate(probe, context) + expect(output["x"]).to include(type: "Integer", value: "42") + expect(output["y"]).to eq(notCapturedReason: "timeout") + expect(errors).to eq([]) + end + + it "increments the timeout counter only for the timed-out expression" do + expect(telemetry).to receive(:inc).with( + "dynamic_instrumentation", "capture_expressions_skipped_by_timeout", 1, + ).once + evaluator.evaluate(probe, context) + end + end + context "per-expression depth limit overrides probe-level depth" do let(:probe) do Datadog::DI::Probe.new( From f0061059c786d44686be46e9cc6d58141a448b4f Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 9 Jun 2026 14:49:06 -0400 Subject: [PATCH 20/42] DI: honor evaluateAt on capture-expression method probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture expressions on method probes now respect the probe's evaluateAt field, matching Python, .NET, and PHP. Before this commit the value was parsed off the RC payload but ignored: every capture-expression block went under captures.return, irrespective of evaluateAt. Probe.evaluate_at — new attribute. Accepts :entry / :exit / nil; nil coerces to :exit (matching the libdatadog ProbeCommon JSON-parse default of EvaluateAt::Exit). Unknown symbols raise ArgumentError at construction. Line probes ignore the value (single firing point at the TracePoint callback). ProbeBuilder.parse_evaluate_at — new helper. Maps the RC payload's 'ENTRY' / 'EXIT' strings to :entry / :exit. 'DEFAULT' (sent by the Java backend tooling) maps to :exit. Absent or unrecognized values coerce to :exit and emit a debug log; the runtime never raises on an unknown evaluateAt because such payloads should still install as conventional EXIT-timed probes. Instrumenter#hook_method — for an evaluate_at: :entry method probe, evaluate the capture expressions against the pre-super scope (self / args / kwargs only, since @return / @duration / @exception are nil at this point) and stash the result on the exit-time Context. Mirrors the existing serialized_entry_args pattern. Context — new entry_capture_expressions and entry_capture_evaluation_errors fields, populated by the entry hook and consumed by ProbeNotificationBuilder. ProbeNotificationBuilder#build_snapshot — for method probes: evaluate_at: :entry emits under captures.entry only, consuming the pre-computed block from Context (does not re-evaluate against exit-time scope); evaluate_at: :exit (default) emits under captures.return only, evaluating against exit-time scope. Never both. Line probes emit under captures.lines..captureExpressions as before; evaluateAt is N/A there. Specs: Probe — evaluate_at keyword acceptance, default coercion, and ArgumentError on unknown values. ProbeBuilder — evaluateAt parsing for ENTRY / EXIT / DEFAULT / absent / unknown. ProbeNotificationBuilder — evaluate_at: :entry consumes the pre-computed block, merges entry-time evaluationErrors, emits empty {} when nothing was stashed. Integration — uses InstrumentationSpecTestClass#mutating_method to exercise the entry-vs-exit distinction: an :entry probe captures the pre-mutation arg value, an :exit probe captures the post-mutation value, demonstrating the timing semantics end-to-end. Steep clean. 860 / 860 DI specs pass (8 pre-existing pending). --- lib/datadog/di/context.rb | 17 ++++ lib/datadog/di/instrumenter.rb | 36 +++++++++ lib/datadog/di/probe.rb | 25 ++++++ lib/datadog/di/probe_builder.rb | 31 ++++++++ lib/datadog/di/probe_notification_builder.rb | 41 ++++++---- sig/datadog/di/context.rbs | 7 +- sig/datadog/di/instrumenter.rbs | 4 + sig/datadog/di/probe.rbs | 6 ++ sig/datadog/di/probe_builder.rbs | 4 + .../di/integration/instrumentation_spec.rb | 78 +++++++++++++++++++ spec/datadog/di/probe_builder_spec.rb | 62 +++++++++++++++ .../di/probe_notification_builder_spec.rb | 58 ++++++++++++++ spec/datadog/di/probe_spec.rb | 63 +++++++++++++++ 13 files changed, 415 insertions(+), 17 deletions(-) diff --git a/lib/datadog/di/context.rb b/lib/datadog/di/context.rb index a5eadec389..50e5f79bdd 100644 --- a/lib/datadog/di/context.rb +++ b/lib/datadog/di/context.rb @@ -14,6 +14,8 @@ def initialize(probe:, settings:, serializer:, locals: nil, target_self: nil, path: nil, caller_locations: nil, serialized_entry_args: nil, + entry_capture_expressions: nil, + entry_capture_evaluation_errors: nil, return_value: nil, duration: nil, exception: nil) @probe = probe @settings = settings @@ -23,6 +25,13 @@ def initialize(probe:, settings:, serializer:, locals: nil, @path = path @caller_locations = caller_locations @serialized_entry_args = serialized_entry_args + # Capture-expression block + evaluation errors collected at the + # entry hook for an evaluate_at: :entry method probe. The exit-time + # ProbeNotificationBuilder consumes these instead of re-evaluating + # against exit-time scope. nil when the probe is exit-timed or has + # no capture expressions. + @entry_capture_expressions = entry_capture_expressions + @entry_capture_evaluation_errors = entry_capture_evaluation_errors @return_value = return_value @duration = duration @exception = exception @@ -44,6 +53,14 @@ def initialize(probe:, settings:, serializer:, locals: nil, attr_reader :serialized_entry_args + # Entry-time capture-expression block, for an evaluate_at: :entry + # method probe. Hash of name => serialized value, or nil. + attr_reader :entry_capture_expressions + + # Per-expression evaluation errors from the entry-time evaluation. + # Array of { expr:, message: } hashes, or nil. + attr_reader :entry_capture_evaluation_errors + # Return value for the method, for a method probe attr_reader :return_value diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index dc802fb23b..4fbd9b9f89 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -2,6 +2,7 @@ require_relative '../core/utils/time' require_relative 'fatal_exceptions' +require_relative 'capture_expression_evaluator' # rubocop:disable Lint/AssignmentInCondition # rubocop:disable Style/AndOr @@ -83,6 +84,17 @@ def initialize(settings, serializer, logger, code_tracker: nil, telemetry: nil) attr_reader :telemetry attr_reader :code_tracker + # Lazily-constructed CaptureExpressionEvaluator used for entry-time + # evaluation on method probes with evaluate_at: :entry. The notification + # builder owns a separate instance for exit-time evaluation; they share + # the same settings/serializer/logger/telemetry and produce identical + # output for an identical context. + def capture_expression_evaluator + @capture_expression_evaluator ||= CaptureExpressionEvaluator.new( + settings: settings, serializer: serializer, logger: logger, telemetry: telemetry, + ) + end + # This is a substitute for Thread::Backtrace::Location # which does not have a public constructor. # Used for the fabricated stack frame for the method itself @@ -214,6 +226,28 @@ def hook_method(probe, responder) serializer.serialize_args(args, kwargs, self, **probe.snapshot_serializer_limits(settings)) end + + # Entry-time capture-expression evaluation. For probes with + # evaluate_at: :entry, evaluate against the pre-super scope + # (args/kwargs/self only — @return/@duration/@exception are + # nil at this point) and stash the result. The Context built + # below carries it through to the notification builder, which + # emits the block under captures.entry instead of re-running + # the evaluator at exit-time scope. + # Skipped when capture_snapshot is also set: the snapshot block + # wins at fire time, so any pre-computed capture-expression block + # would be discarded by ProbeNotificationBuilder. + entry_capture_expressions = nil + entry_capture_evaluation_errors = nil + if probe.capture_expressions? && probe.evaluate_at == :entry && !probe.capture_snapshot? + entry_context = Context.new( + probe: probe, settings: settings, serializer: serializer, + target_self: self, + locals: serializer.combine_args(args, kwargs, self), + ) + entry_capture_expressions, entry_capture_evaluation_errors = + instrumenter.capture_expression_evaluator.evaluate(probe, entry_context) + end # We intentionally do not use Core::Utils::Time.get_time # here because the time provider may be overridden by the # customer, and DI is not allowed to invoke customer code. @@ -282,6 +316,8 @@ def hook_method(probe, responder) context = Context.new(locals: capture_expression_locals, target_self: self, probe: probe, settings: settings, serializer: serializer, serialized_entry_args: serialized_entry_args, + entry_capture_expressions: entry_capture_expressions, + entry_capture_evaluation_errors: entry_capture_evaluation_errors, caller_locations: caller_locs, return_value: rv, duration: duration, exception: exc,) diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index d19fc06c9b..9558ad30ac 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -34,6 +34,13 @@ module DI class Probe KNOWN_TYPES = %i[log].freeze + # Permitted values for the +evaluate_at+ constructor argument. + # +:exit+ is the default applied when +nil+ is passed (this matches the + # libdatadog ProbeCommon JSON-parse default of EvaluateAt::Exit, which + # PHP shares; Python's Snapshot._timing also resolves DEFAULT to EXIT + # for log probes). + EVALUATE_AT_VALUES = %i[entry exit].freeze + def initialize(id:, type:, file: nil, line_no: nil, type_name: nil, method_name: nil, template: nil, template_segments: nil, @@ -41,6 +48,7 @@ def initialize(id:, type:, max_capture_attribute_count: nil, max_capture_collection_size: nil, max_capture_string_length: nil, capture_expressions: [], + evaluate_at: nil, condition: nil, rate_limit: nil) # Perform some sanity checks here to detect unexpected attribute @@ -87,6 +95,16 @@ def initialize(id:, type:, @max_capture_collection_size = max_capture_collection_size @max_capture_string_length = max_capture_string_length @capture_expressions = capture_expressions || [] + # Per-expression evaluation timing on method probes. nil from the + # RC payload (or the constructor default) coerces to +:exit+, matching + # the libdatadog ProbeCommon JSON-parse default (EvaluateAt::Exit). + # Unknown symbols are rejected at construction. Line probes ignore + # this value (single firing point at the TracePoint callback). + evaluate_at = :exit if evaluate_at.nil? + unless EVALUATE_AT_VALUES.include?(evaluate_at) + raise ArgumentError, "Unknown evaluate_at value: #{evaluate_at.inspect} (expected one of #{EVALUATE_AT_VALUES.inspect})" + end + @evaluate_at = evaluate_at @condition = condition # Capture-expression probes are charged against the snapshot rate-limit @@ -144,6 +162,13 @@ def initialize(id:, type:, # @return [Array] attr_reader :capture_expressions + # Per-expression evaluation timing for method probes. One of + # +:entry+ / +:exit+. Defaults to +:exit+ (matching libdatadog's + # ProbeCommon JSON-parse default). Ignored by line probes. + # + # @return [Symbol] + attr_reader :evaluate_at + # Rate limit in effect, in invocations per second. Always present. attr_reader :rate_limit diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index e67ea5802a..afcd01506a 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -34,6 +34,16 @@ module ProbeBuilder # in DataDog/dd-go declares pattern "^[a-zA-Z0-9_?]+$". CAPTURE_EXPRESSION_NAME_PATTERN = /\A[a-zA-Z0-9_?]+\z/ + # Permitted values for the RC payload's `evaluateAt` field. Backend + # schema (DataDog/dd-go live-debugging.json) also accepts "DEFAULT" + # for Java compatibility; we normalize that to :exit at parse time to + # match the libdatadog default. + EVALUATE_AT_STRINGS = { + "ENTRY" => :entry, + "EXIT" => :exit, + "DEFAULT" => :exit, + }.freeze + def build_from_remote_config(config) # The validations here are not yet comprehensive. type = config.fetch('type') @@ -75,6 +85,7 @@ def build_from_remote_config(config) max_capture_collection_size: config["capture"]&.[]("maxCollectionSize"), max_capture_string_length: config["capture"]&.[]("maxLength"), capture_expressions: capture_expressions, + evaluate_at: parse_evaluate_at(config["evaluateAt"], config["id"]), rate_limit: config["sampling"]&.[]("snapshotsPerSecond"), condition: cond, ) @@ -139,6 +150,26 @@ def build_capture_limits(raw) ) end + # Parses the RC payload's `evaluateAt` string into the + # +Datadog::DI::Probe+ symbol form. "ENTRY" → :entry, "EXIT" → :exit, + # "DEFAULT" → :exit (Java sends this; libdatadog also treats it as + # Exit). Absent or unrecognized values coerce to :exit and emit a + # debug log; the runtime never raises on an unknown evaluateAt because + # such payloads should still install as conventional EXIT-timed probes. + # + # @param raw [String, nil] raw `evaluateAt` value from the RC payload. + # @param probe_id [String, nil] probe id for the diagnostic log line. + # @return [Symbol] :entry or :exit. + def parse_evaluate_at(raw, probe_id) + return :exit if raw.nil? + EVALUATE_AT_STRINGS[raw] || begin + Datadog.logger.debug do + "di: probe #{probe_id}: unrecognized evaluateAt value #{raw.inspect}, defaulting to :exit" + end + :exit + end + end + def build_template_segments(segments) segments&.map do |segment| if Hash === segment diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index cf5022cf71..bfaf0cf86d 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -134,24 +134,33 @@ def build_snapshot(context) } end elsif probe.capture_expressions? - captured_block, capture_expression_evaluation_errors = - capture_expression_evaluator.evaluate(probe, context) + # Per-expression evaluation timing on method probes is single-eval, + # honoring probe.evaluate_at (default :exit), matching Python / + # .NET / PHP. evaluate_at: :entry block was evaluated at the entry + # hook (Instrumenter#hook_method) against the pre-super scope; we + # just consume the stashed result. evaluate_at: :exit (default) + # evaluates here, at exit, against the full exit-time scope. + # Line probes ignore evaluate_at and always emit under captures.lines. if probe.method? - # Capture expressions are evaluated once, at method return — the - # only point where args, kwargs, self, @return, and exception are - # all observable. The snapshot schema's entry/return split exists - # for captureSnapshot, which freezes args before super and reads - # self/@return after. captureExpressions has no entry-time - # evaluation, so we do not emit an entry block — populating it - # with the return-time captured_block would mislabel exit-time - # data as entry-time state. - { - return: { - captureExpressions: captured_block, - throwable: context.exception ? serialize_throwable(context.exception) : nil, - }, - } + if probe.evaluate_at == :entry + captured_block = context.entry_capture_expressions || {} + capture_expression_evaluation_errors = context.entry_capture_evaluation_errors || [] + { + entry: {captureExpressions: captured_block}, + } + else + captured_block, capture_expression_evaluation_errors = + capture_expression_evaluator.evaluate(probe, context) + { + return: { + captureExpressions: captured_block, + throwable: context.exception ? serialize_throwable(context.exception) : nil, + }, + } + end elsif probe.line? + captured_block, capture_expression_evaluation_errors = + capture_expression_evaluator.evaluate(probe, context) { lines: { probe.line_no => {captureExpressions: captured_block}, diff --git a/sig/datadog/di/context.rbs b/sig/datadog/di/context.rbs index 39b3021d3a..9d02fd6ffc 100644 --- a/sig/datadog/di/context.rbs +++ b/sig/datadog/di/context.rbs @@ -17,13 +17,16 @@ module Datadog @serialized_entry_args: Hash[Symbol, untyped]? + @entry_capture_expressions: Hash[String, untyped]? + @entry_capture_evaluation_errors: Array[Hash[Symbol, String]]? + @return_value: untyped @duration: Float? @exception: Exception? - def initialize: (probe: Probe, settings: Datadog::Core::Configuration::Settings, serializer: Serializer, ?locals: Hash[Symbol, untyped]?, ?target_self: untyped?, ?path: String?, ?caller_locations: Array[Thread::Backtrace::Location]?, ?serialized_entry_args: Hash[Symbol, untyped]?, ?return_value: untyped?, ?duration: Float?, ?exception: Exception?) -> void + def initialize: (probe: Probe, settings: Datadog::Core::Configuration::Settings, serializer: Serializer, ?locals: Hash[Symbol, untyped]?, ?target_self: untyped?, ?path: String?, ?caller_locations: Array[Thread::Backtrace::Location]?, ?serialized_entry_args: Hash[Symbol, untyped]?, ?entry_capture_expressions: Hash[String, untyped]?, ?entry_capture_evaluation_errors: Array[Hash[Symbol, String]]?, ?return_value: untyped?, ?duration: Float?, ?exception: Exception?) -> void attr_reader probe: Probe @@ -38,6 +41,8 @@ module Datadog attr_reader caller_locations: Array[Thread::Backtrace::Location]? attr_reader serialized_entry_args: Hash[Symbol, untyped]? + attr_reader entry_capture_expressions: Hash[String, untyped]? + attr_reader entry_capture_evaluation_errors: Array[Hash[Symbol, String]]? attr_reader return_value: untyped attr_reader duration: Float? attr_reader exception: Exception? diff --git a/sig/datadog/di/instrumenter.rbs b/sig/datadog/di/instrumenter.rbs index 058530262e..7ffb2fc17b 100644 --- a/sig/datadog/di/instrumenter.rbs +++ b/sig/datadog/di/instrumenter.rbs @@ -23,6 +23,8 @@ module Datadog @lock: Mutex + @capture_expression_evaluator: CaptureExpressionEvaluator? + def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer, DI::Logger logger, ?code_tracker: CodeTracker?, ?telemetry: Core::Telemetry::Component?) -> void attr_reader settings: Datadog::Core::Configuration::Settings @@ -35,6 +37,8 @@ module Datadog attr_reader telemetry: Core::Telemetry::Component? + def capture_expression_evaluator: () -> CaptureExpressionEvaluator + def hook_method: (Probe probe, untyped responder) -> void def unhook_method: (Probe probe) -> void diff --git a/sig/datadog/di/probe.rbs b/sig/datadog/di/probe.rbs index b0f23b5021..bb6d670c4e 100644 --- a/sig/datadog/di/probe.rbs +++ b/sig/datadog/di/probe.rbs @@ -2,6 +2,7 @@ module Datadog module DI class Probe KNOWN_TYPES: Array[Symbol] + EVALUATE_AT_VALUES: Array[Symbol] @id: String @@ -30,11 +31,14 @@ module Datadog @enabled: bool + @evaluate_at: Symbol + def initialize: (id: String, type: Symbol, ?file: String?, ?line_no: Integer?, ?type_name: String?, ?method_name: String?, ?template: String?, ?template_segments: Array[String | DI::EL::Expression]?, ?capture_snapshot: bool, ?condition: DI::EL::Expression?, ?max_capture_depth: Integer?, ?max_capture_attribute_count: Integer?, ?max_capture_collection_size: Integer?, ?max_capture_string_length: Integer?, ?capture_expressions: Array[CaptureExpression], + ?evaluate_at: Symbol?, ?rate_limit: Integer?) -> void attr_reader condition: DI::EL::Expression? @@ -61,6 +65,8 @@ module Datadog attr_reader capture_expressions: Array[CaptureExpression] + attr_reader evaluate_at: Symbol + attr_reader template: String? attr_reader template_segments: Array[String | DI::EL::Expression]? attr_reader rate_limiter: Datadog::Core::RateLimiter diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index a3aa44fd94..031bf0045d 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -5,6 +5,8 @@ module Datadog CAPTURE_EXPRESSION_NAME_PATTERN: Regexp + EVALUATE_AT_STRINGS: Hash[String, Symbol] + def self?.build_from_remote_config: (Hash[String, untyped] config) -> Probe def self?.build_template_segments: (Array[Hash[String, untyped]]? segments) -> Array[String | DI::EL::Expression]? @@ -12,6 +14,8 @@ module Datadog def self?.build_capture_expressions: (Array[Hash[String, untyped]]? raw) -> Array[CaptureExpression] def self?.build_capture_limits: (Hash[String, untyped]? raw) -> CaptureLimits? + + def self?.parse_evaluate_at: (String? raw, String? probe_id) -> Symbol end end end diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 1f811e045c..0a7f8bad4f 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -596,6 +596,84 @@ def run_test end end + context 'with capture expression and evaluateAt: ENTRY' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'mutating_method'}, + evaluateAt: 'ENTRY', + captureExpressions: [ + {name: 'greeting_at_entry', expr: {dsl: 'arg1', json: {ref: 'arg1'}}}, + ], + } + end + + it 'evaluates against pre-super scope and emits under captures.entry' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + greeting = String.new('hello world') + InstrumentationSpecTestClass.new.mutating_method(greeting) + # Confirm the method mutated the arg. + expect(greeting).to eq('bye world') + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + # Entry-time evaluation sees the original value, not the post-mutation one. + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "greeting_at_entry" => {type: 'String', value: 'hello world'}, + ) + # evaluateAt: :entry emits only the entry block; no return block. + expect(captures).not_to have_key(:return) + end + end + + context 'with capture expression and evaluateAt: EXIT (explicit, same as default)' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'mutating_method'}, + evaluateAt: 'EXIT', + captureExpressions: [ + {name: 'greeting_at_exit', expr: {dsl: 'arg1', json: {ref: 'arg1'}}}, + ], + } + end + + it 'evaluates against post-super scope (post-mutation values) and emits under captures.return' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + InstrumentationSpecTestClass.new.mutating_method(String.new('hello world')) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + # Exit-time evaluation sees the post-mutation value. + expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( + "greeting_at_exit" => {type: 'String', value: 'bye world'}, + ) + expect(captures).not_to have_key(:entry) + end + end + context 'with both captureSnapshot=true and capture_expressions (mutual exclusion)' do let(:probe) do Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index efa1c5b1e3..b41fcf4aa9 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -330,5 +330,67 @@ end end end + + describe "evaluateAt parsing" do + let(:base_spec) do + {"id" => "42", "type" => "LOG_PROBE", + "where" => {"typeName" => "Foo", "methodName" => "bar"}} + end + + context "absent" do + let(:rc_probe_spec) { base_spec } + + it "defaults probe.evaluate_at to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "explicit nil" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => nil) } + + it "defaults probe.evaluate_at to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "\"ENTRY\"" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "ENTRY") } + + it "maps to :entry" do + expect(probe.evaluate_at).to eq(:entry) + end + end + + context "\"EXIT\"" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "EXIT") } + + it "maps to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "\"DEFAULT\" (Java sends this)" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "DEFAULT") } + + it "maps to :exit (matching libdatadog default)" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "unrecognized string" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "AROUND") } + + it "falls back to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + + it "logs a debug message naming the bad value" do + expect(Datadog.logger).to receive(:debug) do |&block| + expect(block.call).to match(/unrecognized evaluateAt value "AROUND"/) + end + probe + end + end + end end end diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index a87aa663f0..607e1fb8f2 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -822,6 +822,64 @@ end end + context 'method probe with evaluate_at: :entry' do + let(:probe) do + Datadog::DI::Probe.new(id: '123', type: :log, type_name: 'Foo', method_name: 'bar', + evaluate_at: :entry, + capture_expressions: [capture_expression]) + end + + let(:context) do + Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, + target_self: nil, + entry_capture_expressions: {"x" => {type: "Integer", value: "7"}}, + entry_capture_evaluation_errors: [], + return_value: 999, + ) + end + + it 'emits captureExpressions under the entry block and omits the return block' do + payload = builder.build_snapshot(context) + captures = payload[:debugger][:snapshot][:captures] + expect(captures).to have_key(:entry) + expect(captures).not_to have_key(:return) + expect(captures[:entry][:captureExpressions]).to eq({"x" => {type: "Integer", value: "7"}}) + end + + it 'consumes pre-computed entry-time block; does not re-evaluate against exit scope' do + # context.locals contains {x: 42}, but the stashed entry block has + # {"x" => 7} — confirming the builder uses the pre-computed block and + # does not run the evaluator against the exit-time scope. + payload = builder.build_snapshot(context) + expect(payload[:debugger][:snapshot][:captures][:entry][:captureExpressions]["x"][:value]).to eq("7") + end + + it 'merges entry-time evaluation errors into the snapshot evaluationErrors array' do + context = Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, target_self: nil, + entry_capture_expressions: {}, + entry_capture_evaluation_errors: [{expr: "x", message: "NameError: badvar"}], + ) + payload = builder.build_snapshot(context) + errors = payload[:debugger][:snapshot][:evaluationErrors] + expect(errors).to include(expr: "x", message: "NameError: badvar") + end + + it 'emits an empty captureExpressions hash when no entry block was stashed' do + context = Datadog::DI::Context.new( + settings: settings, serializer: serializer, + probe: probe, target_self: nil, + ) + payload = builder.build_snapshot(context) + captures = payload[:debugger][:snapshot][:captures] + expect(captures[:entry][:captureExpressions]).to eq({}) + expect(captures).not_to have_key(:return) + end + end + context 'evaluation error in a capture expression' do let(:failing_expr) do # len(badvar) — badvar is not in locals, resolves to nil, len(nil) raises diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 24d0ba4b72..038e4ccd13 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -258,6 +258,69 @@ end end + describe "evaluate_at" do + context "omitted" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar") + end + + it "defaults to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "explicitly nil" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + evaluate_at: nil) + end + + it "coerces to :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context ":entry" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + evaluate_at: :entry) + end + + it "is stored as :entry" do + expect(probe.evaluate_at).to eq(:entry) + end + end + + context ":exit" do + let(:probe) do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + evaluate_at: :exit) + end + + it "is stored as :exit" do + expect(probe.evaluate_at).to eq(:exit) + end + end + + context "unknown symbol" do + it "raises ArgumentError" do + expect do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + evaluate_at: :before) + end.to raise_error(ArgumentError, /Unknown evaluate_at value/) + end + end + + context "unknown String value" do + it "raises ArgumentError" do + expect do + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", + evaluate_at: "ENTRY") + end.to raise_error(ArgumentError, /Unknown evaluate_at value/) + end + end + end + describe "#location" do context "method probe" do include_context "method probe" From 536e37bf7c06f2b26f1e12b9a23f0600e4a9f2f6 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 9 Jun 2026 15:58:31 -0400 Subject: [PATCH 21/42] Fix StandardRB Performance/UnfreezeString violations Replace String.new('literal') with unary +'literal' in two test sites added by the evaluateAt commit. The unary + form produces an unfrozen string literal without an extra constructor call, which is what StandardRB's Performance/UnfreezeString cop requires. Both sites use the mutable string to test that mutating_method's in-place sub! changes the entry vs return-time visible value, so the mutability requirement is unchanged. Verified locally: bundle exec rake standard reports no offenses. --- spec/datadog/di/integration/instrumentation_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 0a7f8bad4f..b166d512c2 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -621,7 +621,7 @@ def run_test payload = payload_ end - greeting = String.new('hello world') + greeting = +'hello world' InstrumentationSpecTestClass.new.mutating_method(greeting) # Confirm the method mutated the arg. expect(greeting).to eq('bye world') @@ -662,7 +662,7 @@ def run_test payload = payload_ end - InstrumentationSpecTestClass.new.mutating_method(String.new('hello world')) + InstrumentationSpecTestClass.new.mutating_method(+'hello world') component.probe_notifier_worker.flush captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) From 7c1d6140a3d6068feb1893d24133a936ea877a65 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 10 Jun 2026 13:58:04 -0400 Subject: [PATCH 22/42] DI: drop private-repo path from capture-expression comments --- lib/datadog/di/probe_builder.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index afcd01506a..3f5da93e1c 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -29,15 +29,12 @@ module ProbeBuilder module_function - # Backend JSON schema constraint on capture-expression names: - # remote-config/apps/rc-schema-validation/schemas/live-debugging.json - # in DataDog/dd-go declares pattern "^[a-zA-Z0-9_?]+$". + # Backend JSON schema constraint on capture-expression names. CAPTURE_EXPRESSION_NAME_PATTERN = /\A[a-zA-Z0-9_?]+\z/ - # Permitted values for the RC payload's `evaluateAt` field. Backend - # schema (DataDog/dd-go live-debugging.json) also accepts "DEFAULT" - # for Java compatibility; we normalize that to :exit at parse time to - # match the libdatadog default. + # Permitted values for the RC payload's `evaluateAt` field. The backend + # schema also accepts "DEFAULT" for Java compatibility; we normalize that + # to :exit at parse time to match the libdatadog default. EVALUATE_AT_STRINGS = { "ENTRY" => :entry, "EXIT" => :exit, From 003bb43fd8e07df39fc04fca430076f461856a0c Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 15 Jun 2026 18:10:16 -0400 Subject: [PATCH 23/42] DI: protect entry-time capture-expression evaluation with rescue Mirror the condition-evaluation rescue pattern used a few lines above in the same method. The evaluator itself catches per-expression StandardError; this outer rescue covers non-evaluator failures (building the entry Context, combine_args) so a raise during entry-time evaluation no longer escapes the instrumented method body. propagate_all_exceptions still surfaces failures in the test suite. Otherwise the failure is logged at debug + reported to telemetry, and the method continues with no entry block stashed (the snapshot will omit captures.entry.captureExpressions for this fire, matching the 'no entry block was stashed' fallback already covered by spec). --- lib/datadog/di/instrumenter.rb | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index 4fbd9b9f89..36e3826fdd 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -240,13 +240,27 @@ def hook_method(probe, responder) entry_capture_expressions = nil entry_capture_evaluation_errors = nil if probe.capture_expressions? && probe.evaluate_at == :entry && !probe.capture_snapshot? - entry_context = Context.new( - probe: probe, settings: settings, serializer: serializer, - target_self: self, - locals: serializer.combine_args(args, kwargs, self), - ) - entry_capture_expressions, entry_capture_evaluation_errors = - instrumenter.capture_expression_evaluator.evaluate(probe, entry_context) + begin + entry_context = Context.new( + probe: probe, settings: settings, serializer: serializer, + target_self: self, + locals: serializer.combine_args(args, kwargs, self), + ) + entry_capture_expressions, entry_capture_evaluation_errors = + instrumenter.capture_expression_evaluator.evaluate(probe, entry_context) + rescue => exc + # Per-expression StandardError is caught inside the + # evaluator; reaching this rescue means a non-evaluator + # failure (e.g. building the entry Context or combining + # args raised). Mirror the condition-evaluation pattern + # above: surface in tests via propagate_all_exceptions, + # otherwise log + telemetry and continue with no entry + # block stashed. + raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions + + instrumenter.logger.debug { "di: error evaluating entry-time capture expressions: #{exc.class}: #{exc.message}" } + instrumenter.telemetry&.report(exc, description: "Error evaluating entry-time capture expressions") + end end # We intentionally do not use Core::Utils::Time.get_time # here because the time provider may be overridden by the From e6e0c12c81f42699870feb5832b8e44d07dff69f Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 15 Jun 2026 18:10:24 -0400 Subject: [PATCH 24/42] DI: integration coverage for probe-level capture limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new integration tests cover the bug-fix paths the PR description calls out but the existing maxLength test did not exercise: - Method probe, captureSnapshot=true, max_capture_collection_size=2: verifies array truncation in entry args, @return, and self ivar. Adds InstrumentationSpecTestClass#collection_method as the target. - Line probe at instrumentation_integration_test_class.rb:40, captureSnapshot=true, max_capture_collection_size=1: verifies the redacted hash local is truncated via the line-probe locals path (Context#serialized_locals → serialize_vars → probe-level limits). The earlier maxLength test covered method probes only. These two close the maxCollectionSize and line-probe-locals gaps the PR description claims as fixed. --- .../di/integration/instrumentation_spec.rb | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index b166d512c2..6de287ea0f 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -49,6 +49,10 @@ def binary_data_param_method(binary_param, normal_param) # Method with binary data in parameters binary_param.length + normal_param.length end + + def collection_method(items) + items + end end RSpec.describe 'Instrumentation integration' do @@ -825,6 +829,46 @@ def run_test end end + context 'with snapshot capture and probe-level maxCollectionSize override' do + # Verifies the same plumbing as the maxLength test, for the + # maxCollectionSize override that was previously silently dropped + # before flowing through Probe#snapshot_serializer_limits. + let(:probe) do + Datadog::DI::Probe.new(id: "1234", type: :log, + type_name: 'InstrumentationSpecTestClass', method_name: 'collection_method', + capture_snapshot: true, + max_capture_collection_size: 2,) + end + + it 'truncates arrays in entry args, return value, and self ivar to the probe-level limit' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + instance = InstrumentationSpecTestClass.new + instance.instance_variable_set(:@ivar, [10, 20, 30, 40, 50]) + expect(instance.collection_method([1, 2, 3, 4, 5])).to eq([1, 2, 3, 4, 5]) + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + # Entry arg array passes through serialize_args; only first 2 elements captured. + arg1 = captures.fetch(:entry).fetch(:arguments).fetch(:arg1) + expect(arg1).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) + expect(arg1.fetch(:elements).size).to eq(2) + # @return array passes through serialize_value with the probe-level limit. + ret = captures.fetch(:return).fetch(:arguments).fetch(:@return) + expect(ret).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) + expect(ret.fetch(:elements).size).to eq(2) + # self serialization recurses with the probe-level limit so the ivar array is also truncated. + ivar = captures.fetch(:return).fetch(:arguments).fetch(:self).fetch(:fields).fetch(:@ivar) + expect(ivar).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) + expect(ivar.fetch(:elements).size).to eq(2) + end + end + context 'when target is invoked' do let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, @@ -1439,6 +1483,41 @@ def run_test include_examples 'assembles expected notification payload' end + + context 'with probe-level maxCollectionSize override on line-probe locals' do + # Verifies probe-level capture-limit overrides flow through to + # serialize_vars on the line-probe locals path. Previously + # maxLength / maxCollectionSize were silently dropped here; this + # asserts that probe.snapshot_serializer_limits feeds + # Context#serialized_locals. + let(:probe) do + Datadog::DI::Probe.new(id: "1234", type: :log, + file: 'instrumentation_integration_test_class.rb', line_no: 40, + capture_snapshot: true, + max_capture_collection_size: 1,) + end + + it 'truncates the redacted hash local to the probe-level limit' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + expect(InstrumentationIntegrationTestClass.new.test_method).to eq(42) + component.probe_notifier_worker.flush + + redacted_local = payload.fetch(:debugger).fetch(:snapshot) + .fetch(:captures).fetch(:lines).fetch(40) + .fetch(:locals).fetch(:redacted) + expect(redacted_local).to include( + type: 'Hash', notCapturedReason: 'collectionSize', size: 2, + ) + # Only the first entry (:b => 33) made it under the size cap. + expect(redacted_local.fetch(:entries).size).to eq(1) + end + end end context 'when target file is not loaded initially and is loaded later' do From 5b9e104f82c18442befa2120264b84add820ea0e Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 15 Jun 2026 18:10:28 -0400 Subject: [PATCH 25/42] DI: use instance_double for CaptureExpression expr in probe_spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaptureExpression's RBS signature declares expr as a non-nil DI::EL::Expression. The capture-expression probe specs only exercise storage and probe.capture_expressions? — they never evaluate the expression — so a typed stub keeps the type contract honest without changing what the tests verify. --- spec/datadog/di/probe_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 038e4ccd13..df1f43e797 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -1,5 +1,7 @@ require "datadog/di/spec_helper" require "datadog/di/probe" +require "datadog/di/capture_expression" +require "datadog/di/el" RSpec.describe Datadog::DI::Probe do di_test @@ -200,7 +202,7 @@ describe "capture expressions" do let(:capture_expression) do - Datadog::DI::CaptureExpression.new(name: "x", expr: nil) + Datadog::DI::CaptureExpression.new(name: "x", expr: instance_double(Datadog::DI::EL::Expression)) end context "no capture_expressions argument" do From 894fc417f21bd3889d013073efb854fb4ee98404 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 19:34:52 -0400 Subject: [PATCH 26/42] DI: fix YARD logger type to DI::Logger facade Address review comments (Copilot): the YARD @param/@return types for `logger` documented Datadog::Core::Logger, but DI::Component wraps the core logger in a DI::Logger facade (component.rb:71) before passing it to ProbeNotificationBuilder and CaptureExpressionEvaluator. The RBS sigs already declare DI::Logger; this aligns the YARD docs with actual usage. - Fixed in lib/datadog/di/probe_notification_builder.rb:18 (@param, initializer) - Fixed in lib/datadog/di/probe_notification_builder.rb:42 (@return, attr_reader) - Fixed in lib/datadog/di/capture_expression_evaluator.rb:28 (@param, initializer) - Fixed in lib/datadog/di/capture_expression_evaluator.rb:52 (@return, attr_reader) --- lib/datadog/di/capture_expression_evaluator.rb | 4 ++-- lib/datadog/di/probe_notification_builder.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 4a5aac6b47..adb74a0865 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -25,7 +25,7 @@ class CaptureExpressionEvaluator # @param settings [Datadog::Core::Configuration::Settings] # @param serializer [Datadog::DI::Serializer] - # @param logger [Datadog::Core::Logger] + # @param logger [Datadog::DI::Logger] # @param telemetry [Datadog::Core::Telemetry::Component, nil] nil is # legitimate — Component.build accepts nil telemetry when DI is used # outside a telemetry-configured environment, and Component threads @@ -49,7 +49,7 @@ def initialize(settings:, serializer:, logger:, telemetry: nil) # Logger used for debug-level reporting of per-expression evaluation # failures. - # @return [Datadog::Core::Logger] + # @return [Datadog::DI::Logger] attr_reader :logger # Telemetry component used to report capture-expression timeouts (inc) diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index bfaf0cf86d..38588b574b 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -15,7 +15,7 @@ class ProbeNotificationBuilder # read at payload-build time for service/env/version/tags fields. # @param serializer [Datadog::DI::Serializer] serializer used for snapshot # values (captures, return, throwable). - # @param logger [Datadog::Core::Logger] logger forwarded to the internal + # @param logger [Datadog::DI::Logger] logger forwarded to the internal # CaptureExpressionEvaluator for per-expression evaluation failures. # @param telemetry [Datadog::Core::Telemetry::Component, nil] telemetry # forwarded to the internal CaptureExpressionEvaluator. nil when DI was @@ -39,7 +39,7 @@ def initialize(settings, serializer, logger, telemetry: nil) attr_reader :serializer # Logger; passed through to CaptureExpressionEvaluator at construction. - # @return [Datadog::Core::Logger] + # @return [Datadog::DI::Logger] attr_reader :logger # Telemetry; passed through to CaptureExpressionEvaluator at construction. From 9895aca66cb89d41c190c536c0a849a7a2c481c1 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 19:35:05 -0400 Subject: [PATCH 27/42] DI: correct capture_expression_evaluator construction comment Address review comment (Copilot): the ProbeNotificationBuilder comment described capture_expression_evaluator as "Lazily-constructed", but it is built unconditionally in the initializer. Update the comment to match the implementation. The Instrumenter's evaluator (instrumenter.rb:87) is genuinely lazy (`||=`) and its comment is left unchanged. - Fixed in lib/datadog/di/probe_notification_builder.rb:50 --- lib/datadog/di/probe_notification_builder.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 38588b574b..b5e0d32f46 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -47,8 +47,9 @@ def initialize(settings, serializer, logger, telemetry: nil) # @return [Datadog::Core::Telemetry::Component, nil] attr_reader :telemetry - # Lazily-constructed sub-component that evaluates probe.capture_expressions - # during build_snapshot when probe.capture_expressions? is true. + # Sub-component that evaluates probe.capture_expressions during + # build_snapshot when probe.capture_expressions? is true. Constructed + # in the initializer alongside the other collaborators. # @return [Datadog::DI::CaptureExpressionEvaluator] attr_reader :capture_expression_evaluator From bac314ee3aa5a1f9d7fe91966c8151cb305e5c53 Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 19:42:56 -0400 Subject: [PATCH 28/42] DI: make @duration EL expansion nil-safe at entry time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comment (codex): @duration compiles to `(context.duration * 1000)`. The entry-time capture-expression path added in this PR builds the Context with duration: nil, so any capture expression referencing @duration (even isUndefined(@duration), whose argument is evaluated first) raised NoMethodError on `nil * 1000` and the key was dropped with an evaluation error — unlike @return/@exception, which resolve to nil at entry. Guard the multiplication so @duration resolves to nil (undefined) at entry and still yields duration*1000ms at exit. duration: 0 stays 0 (0 is truthy). - Fixed in lib/datadog/di/el/compiler.rb (@duration expansion) - Added mechanism coverage in spec/datadog/di/el/duration_spec.rb (entry nil -> nil / isUndefined true; exit 0.5 -> 500.0 / isUndefined false) - Added e2e coverage in spec/datadog/di/integration/instrumentation_spec.rb (method probe, evaluateAt: ENTRY, @duration capture expression captured as null value with no evaluation error) Verified: rspec (mechanism 4, e2e 1, EL 147, capture-expression 11, notification/instrumenter/limits/dispatch 154, integration+builder 81), StandardRB clean, Steep clean. --- lib/datadog/di/el/compiler.rb | 8 +++- spec/datadog/di/el/duration_spec.rb | 47 +++++++++++++++++++ .../di/integration/instrumentation_spec.rb | 44 +++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 spec/datadog/di/el/duration_spec.rb diff --git a/lib/datadog/di/el/compiler.rb b/lib/datadog/di/el/compiler.rb index 9e0554483c..b62c4e009b 100644 --- a/lib/datadog/di/el/compiler.rb +++ b/lib/datadog/di/el/compiler.rb @@ -78,7 +78,13 @@ def compile_partial(ast) # We could format to a string here but what if customer # has @duration as part of an expression and wants # to retain it as a number? - "(context.duration * 1000)" + # + # At entry-time evaluation (evaluate_at: :entry) the duration + # is not yet known and context.duration is nil; guarding the + # multiplication makes @duration resolve to nil (undefined) + # instead of raising NoMethodError on nil * 1000, matching how + # @return and @exception resolve to nil in the entry context. + "(context.duration && context.duration * 1000)" when '@exception' "context.exception" else diff --git a/spec/datadog/di/el/duration_spec.rb b/spec/datadog/di/el/duration_spec.rb new file mode 100644 index 0000000000..1048001684 --- /dev/null +++ b/spec/datadog/di/el/duration_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../spec_helper' +require 'datadog/di/el' + +RSpec.describe 'DI EL @duration' do + di_test + + let(:compiler) { Datadog::DI::EL::Compiler.new } + + let(:context) do + Datadog::DI::Context.new( + probe: nil, settings: nil, serializer: nil, + duration: duration, + ) + end + + def evaluate(ast) + compiled = compiler.compile(ast) + Datadog::DI::EL::Expression.new('(expression)', compiled).evaluate(context) + end + + context 'at entry time, when duration is nil' do + let(:duration) { nil } + + it 'resolves @duration to nil instead of raising' do + expect(evaluate('ref' => '@duration')).to be_nil + end + + it 'resolves isUndefined(@duration) to true instead of raising' do + expect(evaluate('isUndefined' => {'ref' => '@duration'})).to be(true) + end + end + + context 'at exit time, when duration is known' do + let(:duration) { 0.5 } + + it 'scales @duration to milliseconds' do + expect(evaluate('ref' => '@duration')).to eq(500.0) + end + + it 'resolves isUndefined(@duration) to false' do + expect(evaluate('isUndefined' => {'ref' => '@duration'})).to be(false) + end + end +end diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 6de287ea0f..3020e8bc18 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -641,6 +641,50 @@ def run_test end end + context 'with @duration capture expression and evaluateAt: ENTRY' do + let(:probe) do + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + end + + # At entry time the duration is not yet known (context.duration is nil). + # @duration must resolve to nil (undefined) rather than raising, so the + # key is captured as a null value instead of being dropped with an + # evaluation error. + let(:probe_spec) do + { + id: '1234', + type: 'LOG_PROBE', + where: {typeName: 'InstrumentationSpecTestClass', methodName: 'mutating_method'}, + evaluateAt: 'ENTRY', + captureExpressions: [ + {name: 'duration_at_entry', expr: {dsl: '@duration', json: {ref: '@duration'}}}, + ], + } + end + + it 'captures @duration as an undefined (nil) value without an evaluation error' do + expect(diagnostics_transport).to receive(:send_diagnostics) + probe_manager.add_probe(probe) + payload = nil + expect(component.probe_notifier_worker).to receive(:add_snapshot) do |payload_| + payload = payload_ + end + + InstrumentationSpecTestClass.new.mutating_method(+'hello world') + component.probe_notifier_worker.flush + + snapshot = payload.fetch(:debugger).fetch(:snapshot) + captures = snapshot.fetch(:captures) + # The key is present with a null value, not omitted. + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "duration_at_entry" => {type: 'NilClass', isNull: true}, + ) + expect(captures).not_to have_key(:return) + # No evaluation error is recorded for the expression. + expect(snapshot.fetch(:evaluationErrors, [])).to be_empty + end + end + context 'with capture expression and evaluateAt: EXIT (explicit, same as default)' do let(:probe) do Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) From a08310198bf58792ee3549d98ab569d48044713c Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 20:31:28 -0400 Subject: [PATCH 29/42] DI: drop exit-time consumer sentence from entry-capture comment Remove the sentence describing how the exit-time ProbeNotificationBuilder consumes the entry-time capture-expression block, per review direction. The remaining comment still documents what the fields hold and when they are nil. --- lib/datadog/di/context.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/datadog/di/context.rb b/lib/datadog/di/context.rb index 50e5f79bdd..87981f7a75 100644 --- a/lib/datadog/di/context.rb +++ b/lib/datadog/di/context.rb @@ -26,10 +26,8 @@ def initialize(probe:, settings:, serializer:, locals: nil, @caller_locations = caller_locations @serialized_entry_args = serialized_entry_args # Capture-expression block + evaluation errors collected at the - # entry hook for an evaluate_at: :entry method probe. The exit-time - # ProbeNotificationBuilder consumes these instead of re-evaluating - # against exit-time scope. nil when the probe is exit-timed or has - # no capture expressions. + # entry hook for an evaluate_at: :entry method probe. nil when the + # probe is exit-timed or has no capture expressions. @entry_capture_expressions = entry_capture_expressions @entry_capture_evaluation_errors = entry_capture_evaluation_errors @return_value = return_value From dea1048aa483df19e290f332eecb7be94201c9ae Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 20:33:09 -0400 Subject: [PATCH 30/42] DI: trim entry-capture comment in instrumenter Rename "stash" to "store" and drop the clause describing how the notification builder emits the block under captures.entry, per review direction. The comment still states what the entry-time path does and that the Context carries the result to the notification builder. --- lib/datadog/di/instrumenter.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index 36e3826fdd..c698b0383c 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -230,10 +230,8 @@ def hook_method(probe, responder) # Entry-time capture-expression evaluation. For probes with # evaluate_at: :entry, evaluate against the pre-super scope # (args/kwargs/self only — @return/@duration/@exception are - # nil at this point) and stash the result. The Context built - # below carries it through to the notification builder, which - # emits the block under captures.entry instead of re-running - # the evaluator at exit-time scope. + # nil at this point) and store the result. The Context built + # below carries it through to the notification builder. # Skipped when capture_snapshot is also set: the snapshot block # wins at fire time, so any pre-computed capture-expression block # would be discarded by ProbeNotificationBuilder. From 3dae84be144935d850a6941b528979caeae0d75e Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 20:40:25 -0400 Subject: [PATCH 31/42] DI: apply #5945 fatal-exception handling to capture-expression rescues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #5945 (Rescue all exceptions in DI and SymDB) converted DI/SymDB catch-all rescues to `rescue Exception` + Datadog::DI.reraise_if_fatal, so non-StandardError exceptions do not escape into customer code while fatal exceptions (SystemExit, SignalException, NoMemoryError) still propagate. That merge covered the code present at the time; this PR later added two new catch-all rescues that still used `rescue => e` (StandardError only): - lib/datadog/di/capture_expression_evaluator.rb: per-expression rescue (runs customer expression code) — now rescue Exception + reraise_if_fatal, with require_relative "fatal_exceptions" - lib/datadog/di/instrumenter.rb: entry-time capture-expression rescue — now rescue Exception + reraise_if_fatal, matching the sibling condition-evaluation rescue above it Added evaluator coverage mirroring #5945's tests: a non-StandardError (NotImplementedError) is caught and recorded as an evaluation error; a fatal exception (SystemExit) is re-raised out of #evaluate. Verified: rspec (evaluator + instrumenter + integration + duration = 174, 0 failures), StandardRB clean, Steep clean. --- .../di/capture_expression_evaluator.rb | 4 +- lib/datadog/di/instrumenter.rb | 3 +- .../di/capture_expression_evaluator_spec.rb | 52 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index adb74a0865..bf8ab9b605 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -2,6 +2,7 @@ require_relative "capture_expression" require_relative "capture_limits" +require_relative "fatal_exceptions" module Datadog module DI @@ -98,7 +99,8 @@ def evaluate(probe, context) length: limits[:length], collection_size: limits[:collection_size], ) - rescue => exc + rescue Exception => exc # standard:disable Lint/RescueException + Datadog::DI.reraise_if_fatal(exc) evaluation_errors << {expr: name, message: "#{exc.class}: #{exc.message}"} logger.debug do "di: probe #{probe.id}: capture expression #{name}: evaluation failed: #{exc.class}: #{exc.message}" diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index c698b0383c..5ffbffd429 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -246,7 +246,8 @@ def hook_method(probe, responder) ) entry_capture_expressions, entry_capture_evaluation_errors = instrumenter.capture_expression_evaluator.evaluate(probe, entry_context) - rescue => exc + rescue Exception => exc # standard:disable Lint/RescueException + Datadog::DI.reraise_if_fatal(exc) # Per-expression StandardError is caught inside the # evaluator; reaching this rescue means a non-evaluator # failure (e.g. building the entry Context or combining diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb index f0d856b0a5..07c379356e 100644 --- a/spec/datadog/di/capture_expression_evaluator_spec.rb +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -105,6 +105,58 @@ def compile_expression(dsl_string, json) end end + context "expression evaluation raises a non-StandardError" do + # NotImplementedError is not a StandardError, so a plain `rescue => e` + # would let it escape into the customer method. The evaluator rescues + # Exception and re-raises only fatal exceptions, so a non-fatal + # non-StandardError is recorded as an evaluation error like any other. + let(:expr) { instance_double(Datadog::DI::EL::Expression) } + + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new(name: "boom", expr: expr), + ], + ) + end + + before do + allow(expr).to receive(:evaluate).and_raise(NotImplementedError, "nope") + end + + it "catches it, omits the key, and records an evaluation error" do + output, errors = evaluator.evaluate(probe, context) + expect(output).to eq({}) + expect(errors.size).to eq(1) + expect(errors.first[:expr]).to eq("boom") + expect(errors.first[:message]).to include("NotImplementedError") + end + end + + context "expression evaluation raises a fatal exception" do + # Fatal exceptions (process teardown / OOM) must never be swallowed by + # the catch-all rescue; reraise_if_fatal propagates them out of #evaluate. + let(:expr) { instance_double(Datadog::DI::EL::Expression) } + + let(:probe) do + Datadog::DI::Probe.new( + id: "p1", type: :log, type_name: "F", method_name: "m", + capture_expressions: [ + Datadog::DI::CaptureExpression.new(name: "boom", expr: expr), + ], + ) + end + + before do + allow(expr).to receive(:evaluate).and_raise(SystemExit.new) + end + + it "re-raises the fatal exception instead of recording an error" do + expect { evaluator.evaluate(probe, context) }.to raise_error(SystemExit) + end + end + context "mixed success and failure" do let(:probe) do Datadog::DI::Probe.new( From b5cd07b8f3fb69300ce4386356cd7fb29104b5ef Mon Sep 17 00:00:00 2001 From: ddsign Date: Wed, 1 Jul 2026 20:52:07 -0400 Subject: [PATCH 32/42] DI: inject logger into ProbeBuilder instead of Datadog.logger global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR added two Datadog.logger.debug calls in ProbeBuilder (master had none), reaching for the global logger from module_function code — a Component Pattern violation (component code must use injected dependencies, not globals). Both log paths originate from build_from_remote_config (the second via parse_evaluate_at), and the sole production caller, Component#parse_probe_spec_and_notify, already has the injected DI::Logger facade in scope. - probe_builder.rb: build_from_remote_config now takes a required `logger:` kwarg and threads it into parse_evaluate_at; both Datadog.logger.debug calls become logger.debug - component.rb: pass the component's injected logger at the call site - probe_builder.rbs: update the two signatures - specs: inject a logger double at every build_from_remote_config call site; the two probe_builder_spec assertions now expect on the injected logger rather than the Datadog.logger global Verified: rspec (probe_builder, component, integration instrumentation + notification builder, remote = 137, 0 failures), StandardRB clean, Steep clean. --- lib/datadog/di/component.rb | 2 +- lib/datadog/di/probe_builder.rb | 12 +++++++----- sig/datadog/di/probe_builder.rbs | 4 ++-- .../di/integration/instrumentation_spec.rb | 18 +++++++++--------- .../probe_notification_builder_spec.rb | 2 +- spec/datadog/di/probe_builder_spec.rb | 9 ++++++--- spec/datadog/di/remote_spec.rb | 2 +- 7 files changed, 27 insertions(+), 22 deletions(-) diff --git a/lib/datadog/di/component.rb b/lib/datadog/di/component.rb index 6a5dede431..1d6227de8c 100644 --- a/lib/datadog/di/component.rb +++ b/lib/datadog/di/component.rb @@ -185,7 +185,7 @@ def shutdown!(replacement = nil) end def parse_probe_spec_and_notify(probe_spec) - probe = ProbeBuilder.build_from_remote_config(probe_spec) + probe = ProbeBuilder.build_from_remote_config(probe_spec, logger: logger) rescue Exception => exc # standard:disable Lint/RescueException Datadog::DI.reraise_if_fatal(exc) begin diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 3f5da93e1c..a68ac8e2b0 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -41,7 +41,7 @@ module ProbeBuilder "DEFAULT" => :exit, }.freeze - def build_from_remote_config(config) + def build_from_remote_config(config, logger:) # The validations here are not yet comprehensive. type = config.fetch('type') type_symbol = PROBE_TYPES[type] or raise ArgumentError, "Unrecognized probe type: #{type}" @@ -59,7 +59,7 @@ def build_from_remote_config(config) # drops capture-expression values (snapshot wins), matching # Python/Java/Go DI. Logged at debug to make the choice observable # without spamming operator logs. - Datadog.logger.debug do + logger.debug do "di: probe #{config["id"]}: captureSnapshot=true wins over captureExpressions (n=#{capture_expressions.size})" end end @@ -82,7 +82,7 @@ def build_from_remote_config(config) max_capture_collection_size: config["capture"]&.[]("maxCollectionSize"), max_capture_string_length: config["capture"]&.[]("maxLength"), capture_expressions: capture_expressions, - evaluate_at: parse_evaluate_at(config["evaluateAt"], config["id"]), + evaluate_at: parse_evaluate_at(config["evaluateAt"], config["id"], logger), rate_limit: config["sampling"]&.[]("snapshotsPerSecond"), condition: cond, ) @@ -156,11 +156,13 @@ def build_capture_limits(raw) # # @param raw [String, nil] raw `evaluateAt` value from the RC payload. # @param probe_id [String, nil] probe id for the diagnostic log line. + # @param logger [Datadog::DI::Logger] injected logger for the diagnostic + # log line emitted on an unrecognized value. # @return [Symbol] :entry or :exit. - def parse_evaluate_at(raw, probe_id) + def parse_evaluate_at(raw, probe_id, logger) return :exit if raw.nil? EVALUATE_AT_STRINGS[raw] || begin - Datadog.logger.debug do + logger.debug do "di: probe #{probe_id}: unrecognized evaluateAt value #{raw.inspect}, defaulting to :exit" end :exit diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index 031bf0045d..4efed5d330 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -7,7 +7,7 @@ module Datadog EVALUATE_AT_STRINGS: Hash[String, Symbol] - def self?.build_from_remote_config: (Hash[String, untyped] config) -> Probe + def self?.build_from_remote_config: (Hash[String, untyped] config, logger: DI::Logger) -> Probe def self?.build_template_segments: (Array[Hash[String, untyped]]? segments) -> Array[String | DI::EL::Expression]? @@ -15,7 +15,7 @@ module Datadog def self?.build_capture_limits: (Hash[String, untyped]? raw) -> CaptureLimits? - def self?.parse_evaluate_at: (String? raw, String? probe_id) -> Symbol + def self?.parse_evaluate_at: (String? raw, String? probe_id, DI::Logger logger) -> Symbol end end end diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 3020e8bc18..805ff1b41c 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -566,7 +566,7 @@ def run_test context 'with capture expression referencing a positional argument' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -602,7 +602,7 @@ def run_test context 'with capture expression and evaluateAt: ENTRY' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -643,7 +643,7 @@ def run_test context 'with @duration capture expression and evaluateAt: ENTRY' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end # At entry time the duration is not yet known (context.duration is nil). @@ -687,7 +687,7 @@ def run_test context 'with capture expression and evaluateAt: EXIT (explicit, same as default)' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -724,7 +724,7 @@ def run_test context 'with both captureSnapshot=true and capture_expressions (mutual exclusion)' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -764,7 +764,7 @@ def run_test end let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -799,7 +799,7 @@ def run_test context 'with a capture expression that raises during evaluation' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end # len(undefined) — `undefined` resolves to nil at the EL layer, and @@ -956,7 +956,7 @@ def run_test context 'when message template references special variables' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:probe_spec) do @@ -1246,7 +1246,7 @@ def run_test context 'with capture expression referencing a local variable' do let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end # Line 40 of the test target reads `a * 2` after `a = 21` on line 33. diff --git a/spec/datadog/di/integration/probe_notification_builder_spec.rb b/spec/datadog/di/integration/probe_notification_builder_spec.rb index c0c7c11ce4..6e3b44272a 100644 --- a/spec/datadog/di/integration/probe_notification_builder_spec.rb +++ b/spec/datadog/di/integration/probe_notification_builder_spec.rb @@ -151,7 +151,7 @@ end let(:probe) do - Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) + Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end let(:context) do diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index b41fcf4aa9..690e63ca31 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -1,12 +1,15 @@ require "datadog/di/spec_helper" require "datadog/di/probe_builder" +require "datadog/di/logger" RSpec.describe Datadog::DI::ProbeBuilder do di_test + let(:logger) { instance_double(Datadog::DI::Logger).as_null_object } + describe ".build_from_remote_config" do let(:probe) do - described_class.build_from_remote_config(rc_probe_spec) + described_class.build_from_remote_config(rc_probe_spec, logger: logger) end context "typical line probe" do @@ -291,7 +294,7 @@ end it "logs a debug message about snapshot winning" do - expect(Datadog.logger).to receive(:debug) do |&block| + expect(logger).to receive(:debug) do |&block| expect(block.call).to match(/captureSnapshot=true wins over captureExpressions/) end probe @@ -385,7 +388,7 @@ end it "logs a debug message naming the bad value" do - expect(Datadog.logger).to receive(:debug) do |&block| + expect(logger).to receive(:debug) do |&block| expect(block.call).to match(/unrecognized evaluateAt value "AROUND"/) end probe diff --git a/spec/datadog/di/remote_spec.rb b/spec/datadog/di/remote_spec.rb index 2a519c1251..2f79b6a329 100644 --- a/spec/datadog/di/remote_spec.rb +++ b/spec/datadog/di/remote_spec.rb @@ -387,7 +387,7 @@ def options_snapshot(di_settings) {id: '11', name: 'bar', type: 'LOG_PROBE', where: {typeName: 'Foo', methodName: 'bar'}} end - let(:probe) { Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json)) } + let(:probe) { Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) } before do # Uncomment for debugging: From 81ffa25051662df11c27c8aab0ad8ae2e82e195d Mon Sep 17 00:00:00 2001 From: ddsign Date: Thu, 2 Jul 2026 19:25:29 -0400 Subject: [PATCH 33/42] Use ASCII characters in capture-expression comments Replace em-dash and arrow characters introduced by this PR's comments with ASCII equivalents (-- and ->). --- lib/datadog/di/capture_expression_evaluator.rb | 2 +- lib/datadog/di/capture_limits.rb | 2 +- lib/datadog/di/instrumenter.rb | 4 ++-- lib/datadog/di/probe.rb | 2 +- lib/datadog/di/probe_builder.rb | 4 ++-- spec/datadog/di/integration/instrumentation_spec.rb | 2 +- spec/datadog/di/probe_notification_builder_spec.rb | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index bf8ab9b605..956dd6fd35 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -28,7 +28,7 @@ class CaptureExpressionEvaluator # @param serializer [Datadog::DI::Serializer] # @param logger [Datadog::DI::Logger] # @param telemetry [Datadog::Core::Telemetry::Component, nil] nil is - # legitimate — Component.build accepts nil telemetry when DI is used + # legitimate -- Component.build accepts nil telemetry when DI is used # outside a telemetry-configured environment, and Component threads # the nil through to here. All telemetry calls below are nil-guarded. def initialize(settings:, serializer:, logger:, telemetry: nil) diff --git a/lib/datadog/di/capture_limits.rb b/lib/datadog/di/capture_limits.rb index 5e3ac1e86e..05afacdf2c 100644 --- a/lib/datadog/di/capture_limits.rb +++ b/lib/datadog/di/capture_limits.rb @@ -48,7 +48,7 @@ def initialize(max_reference_depth: nil, max_collection_size: nil, # # expr_limits.X ?? probe.max_capture_X ?? settings.dynamic_instrumentation.max_capture_X # - # Resolved independently per field — a missing field on the + # Resolved independently per field -- a missing field on the # expression's limits falls through to the probe level, then to # the language-wide default, without affecting the other fields. # diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index a37e01cf61..1a16a76ba0 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -537,7 +537,7 @@ def run_method_probe(args, kwargs, target_block, target_self, # Entry-time capture-expression evaluation. For probes with # evaluate_at: :entry, evaluate against the pre-super scope - # (args/kwargs/self only — @return/@duration/@exception are + # (args/kwargs/self only -- @return/@duration/@exception are # nil at this point) and store the result. The Context built # below carries it through to the notification builder. # Skipped when capture_snapshot is also set: the snapshot block @@ -635,7 +635,7 @@ def run_method_probe(args, kwargs, target_block, target_self, # arg references when the probe has capture expressions; values # may reflect in-place mutation by the method body, matching the # return-time scope semantics. - # Skip when capture_snapshot is also set — the snapshot wins at + # Skip when capture_snapshot is also set -- the snapshot wins at # build time, so the combined hash would be discarded. capture_expression_locals = if probe.capture_expressions? && !probe.capture_snapshot? serializer.combine_args(args, kwargs, target_self) diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index 4e0afe87a7..9ea99dfd72 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -203,7 +203,7 @@ def capture_expressions? # @param settings [Datadog::Core::Configuration::Settings] tracer settings # providing the dynamic_instrumentation.max_capture_* fallback values. # @return [Hash{Symbol => Integer}] hash with keys :depth, :attribute_count, - # :length, :collection_size — all values are Integers (no nils). + # :length, :collection_size -- all values are Integers (no nils). def snapshot_serializer_limits(settings) di = settings.dynamic_instrumentation { diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index a68ac8e2b0..4d4229b7c6 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -148,8 +148,8 @@ def build_capture_limits(raw) end # Parses the RC payload's `evaluateAt` string into the - # +Datadog::DI::Probe+ symbol form. "ENTRY" → :entry, "EXIT" → :exit, - # "DEFAULT" → :exit (Java sends this; libdatadog also treats it as + # +Datadog::DI::Probe+ symbol form. "ENTRY" -> :entry, "EXIT" -> :exit, + # "DEFAULT" -> :exit (Java sends this; libdatadog also treats it as # Exit). Absent or unrecognized values coerce to :exit and emit a # debug log; the runtime never raises on an unknown evaluateAt because # such payloads should still install as conventional EXIT-timed probes. diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 805ff1b41c..6011c6a351 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -802,7 +802,7 @@ def run_test Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end - # len(undefined) — `undefined` resolves to nil at the EL layer, and + # len(undefined) -- `undefined` resolves to nil at the EL layer, and # len(nil) raises ExpressionEvaluationError, which the evaluator # catches and reports via evaluationErrors. let(:probe_spec) do diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index 607e1fb8f2..b106319db8 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -850,7 +850,7 @@ it 'consumes pre-computed entry-time block; does not re-evaluate against exit scope' do # context.locals contains {x: 42}, but the stashed entry block has - # {"x" => 7} — confirming the builder uses the pre-computed block and + # {"x" => 7} -- confirming the builder uses the pre-computed block and # does not run the evaluator against the exit-time scope. payload = builder.build_snapshot(context) expect(payload[:debugger][:snapshot][:captures][:entry][:captureExpressions]["x"][:value]).to eq("7") @@ -882,7 +882,7 @@ context 'evaluation error in a capture expression' do let(:failing_expr) do - # len(badvar) — badvar is not in locals, resolves to nil, len(nil) raises + # len(badvar) -- badvar is not in locals, resolves to nil, len(nil) raises compiled = Datadog::DI::EL::Compiler.new.compile({"len" => {"ref" => "badvar"}}) Datadog::DI::EL::Expression.new("len(badvar)", compiled) end From 14ee27028896f6834557938bfc5808d13b754116 Mon Sep 17 00:00:00 2001 From: ddsign Date: Thu, 2 Jul 2026 19:25:29 -0400 Subject: [PATCH 34/42] Use instance_double for settings in capture-limit specs Replace string-name double("settings", ...) with instance_double(Datadog::Core::Configuration::Settings, ...) in the CaptureLimits.resolve and Probe#snapshot_serializer_limits specs so the settings interface is verified. --- spec/datadog/di/capture_limits_spec.rb | 2 +- spec/datadog/di/probe_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/datadog/di/capture_limits_spec.rb b/spec/datadog/di/capture_limits_spec.rb index 47e72ed762..37f94bc75a 100644 --- a/spec/datadog/di/capture_limits_spec.rb +++ b/spec/datadog/di/capture_limits_spec.rb @@ -30,7 +30,7 @@ describe ".resolve" do let(:settings) do - double("settings", dynamic_instrumentation: double( + instance_double(Datadog::Core::Configuration::Settings, dynamic_instrumentation: double( max_capture_depth: 3, max_capture_collection_size: 100, max_capture_string_length: 255, diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 4c4dcc6688..3dffb1fd34 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -363,7 +363,7 @@ describe "#snapshot_serializer_limits" do let(:settings) do - double("settings", dynamic_instrumentation: double( + instance_double(Datadog::Core::Configuration::Settings, dynamic_instrumentation: double( max_capture_depth: 3, max_capture_attribute_count: 20, max_capture_string_length: 255, From 89ab76a2d5eb96ef2425217ec9b34f55101eb9de Mon Sep 17 00:00:00 2001 From: ddsign Date: Thu, 2 Jul 2026 21:25:57 -0400 Subject: [PATCH 35/42] DI: collapse duplicate capture-expression names at parse time The backend permits duplicate captureExpressions names and forwards them unchanged. ProbeBuilder now collapses entries sharing a name, keeping the last occurrence, so each name is evaluated once instead of being evaluated and then overwritten when the snapshot is serialized. The collapse is logged at debug. --- lib/datadog/di/probe_builder.rb | 27 +++++++++++++++++++++++++++ sig/datadog/di/probe_builder.rbs | 2 ++ spec/datadog/di/probe_builder_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 4d4229b7c6..c091747297 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -53,6 +53,7 @@ def build_from_remote_config(config, logger:) EL::Expression.new(cond_spec['dsl'], compiled) end capture_expressions = build_capture_expressions(config['captureExpressions']) + capture_expressions = dedup_capture_expressions(capture_expressions, config["id"], logger) if !!config["captureSnapshot"] && !capture_expressions.empty? # When both captureSnapshot and captureExpressions are set on the # same probe, the runtime emits the snapshot block and silently @@ -126,6 +127,32 @@ def build_capture_expressions(raw) end end + # Collapses capture expressions that share a name, keeping the last + # occurrence for each name. Duplicate names are permitted on the wire and + # are forwarded unchanged; resolving them here, at parse time, yields one + # value per name and avoids evaluating an expression whose result would be + # overwritten when the snapshot is serialized. A collapse is logged at + # debug so it stays observable. + # + # @param capture_expressions [Array] + # parsed capture expressions, in payload order. + # @param probe_id [String, nil] probe id for the diagnostic log line. + # @param logger [Datadog::DI::Logger] injected logger. + # @return [Array] one per distinct name, + # with the last occurrence retained for each. + def dedup_capture_expressions(capture_expressions, probe_id, logger) + return capture_expressions if capture_expressions.size < 2 + by_name = {} + capture_expressions.each do |capture_expression| + by_name[capture_expression.name] = capture_expression + end + return capture_expressions if by_name.size == capture_expressions.size + logger.debug do + "di: probe #{probe_id}: collapsed duplicate captureExpressions names, kept last per name (#{capture_expressions.size} -> #{by_name.size})" + end + by_name.values + end + # Parses a per-expression `capture` block of a remote-config probe payload # into a CaptureLimits instance. # diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index 4efed5d330..7f4d5885ed 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -13,6 +13,8 @@ module Datadog def self?.build_capture_expressions: (Array[Hash[String, untyped]]? raw) -> Array[CaptureExpression] + def self?.dedup_capture_expressions: (Array[CaptureExpression] capture_expressions, String? probe_id, DI::Logger logger) -> Array[CaptureExpression] + def self?.build_capture_limits: (Hash[String, untyped]? raw) -> CaptureLimits? def self?.parse_evaluate_at: (String? raw, String? probe_id, DI::Logger logger) -> Symbol diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index 690e63ca31..4904b0246c 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -301,6 +301,29 @@ end end + context "duplicate captureExpressions names" do + let(:rc_probe_spec) do + base_spec.merge("captureExpressions" => [ + {"name" => "x", "expr" => {"dsl" => "a", "json" => {"ref" => "a"}}}, + {"name" => "y", "expr" => {"dsl" => "b", "json" => {"ref" => "b"}}}, + {"name" => "x", "expr" => {"dsl" => "c", "json" => {"ref" => "c"}}}, + ]) + end + + it "collapses to one entry per name, keeping the last occurrence" do + expect(probe.capture_expressions.map(&:name)).to eq(%w[x y]) + x = probe.capture_expressions.find { |ce| ce.name == "x" } + expect(x.expr.dsl_expr).to eq("c") + end + + it "logs a debug message about the collapse" do + expect(logger).to receive(:debug) do |&block| + expect(block.call).to match(/collapsed duplicate captureExpressions names/) + end + probe + end + end + context "captureExpressions is not an array" do let(:rc_probe_spec) do base_spec.merge("captureExpressions" => {"name" => "x"}) From 73c860e0f57d63c9620271782eccb83cbd3c7368 Mon Sep 17 00:00:00 2001 From: ddsign Date: Thu, 2 Jul 2026 21:34:48 -0400 Subject: [PATCH 36/42] DI: note cross-tracer last-wins parity in dedup comment --- lib/datadog/di/probe_builder.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index c091747297..32bce2ecdc 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -129,10 +129,11 @@ def build_capture_expressions(raw) # Collapses capture expressions that share a name, keeping the last # occurrence for each name. Duplicate names are permitted on the wire and - # are forwarded unchanged; resolving them here, at parse time, yields one - # value per name and avoids evaluating an expression whose result would be - # overwritten when the snapshot is serialized. A collapse is logged at - # debug so it stays observable. + # are forwarded unchanged. The resulting last-wins output matches + # Python/Java/Node.js/PHP; unlike those tracers, which evaluate every + # entry and let a later value overwrite an earlier one at snapshot time, + # Ruby resolves duplicates here at parse time so a discarded duplicate is + # never evaluated. A collapse is logged at debug so it stays observable. # # @param capture_expressions [Array] # parsed capture expressions, in payload order. From 8700aac9a057b5d2d98063ebdbaf3d1eefb94cb5 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 13 Jul 2026 16:21:55 -0400 Subject: [PATCH 37/42] =?UTF-8?q?DI:=20capture=20expressions=20=E2=80=94?= =?UTF-8?q?=20remove=20explanatory=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strips all added non-pragma comment lines (374 across lib and spec) from the capture-expressions change. Functional pragmas (frozen_string_literal, standard:disable, steep:ignore) are kept. Code and tests unchanged. --- lib/datadog/di/capture_expression.rb | 25 --------- .../di/capture_expression_evaluator.rb | 45 ---------------- lib/datadog/di/capture_limits.rb | 45 ---------------- lib/datadog/di/configuration.rb | 10 ---- lib/datadog/di/context.rb | 7 --- lib/datadog/di/el/compiler.rb | 6 --- lib/datadog/di/instrumenter.rb | 27 ---------- lib/datadog/di/probe.rb | 38 ------------- lib/datadog/di/probe_builder.rb | 54 ------------------- lib/datadog/di/probe_notification_builder.rb | 37 ------------- lib/datadog/di/serializer.rb | 30 ----------- .../di/capture_expression_evaluator_spec.rb | 16 ------ .../di/integration/instrumentation_spec.rb | 30 ----------- .../di/probe_notification_builder_spec.rb | 4 -- 14 files changed, 374 deletions(-) diff --git a/lib/datadog/di/capture_expression.rb b/lib/datadog/di/capture_expression.rb index fdbb14cd92..28f8205e38 100644 --- a/lib/datadog/di/capture_expression.rb +++ b/lib/datadog/di/capture_expression.rb @@ -4,42 +4,17 @@ module Datadog module DI - # Configured capture expression on a log probe. - # - # Carries a user-supplied +name+ (used as the key in the snapshot - # output), a compiled +expr+ from the DI expression language, and - # optional per-expression +limits+ overriding the probe-level - # capture limits. - # - # Pure value object: every attribute round-trips through remote - # configuration and the snapshot payload; no in-process-only fields. - # - # @api private class CaptureExpression - # @param name [String] user-supplied key under which this expression's - # serialized value appears in the snapshot's captureExpressions block. - # Validated against /\A[a-zA-Z0-9_?]+\z/ by ProbeBuilder. - # @param expr [Datadog::DI::EL::Expression] compiled DSL expression - # evaluated against the probe context at probe-fire time. - # @param limits [Datadog::DI::CaptureLimits, nil] optional per-expression - # capture-limit overrides. nil falls back to probe-level then - # settings-level limits independently per field via CaptureLimits.resolve. def initialize(name:, expr:, limits: nil) @name = name @expr = expr @limits = limits end - # User-supplied snapshot key. See `initialize` for charset constraints. - # @return [String] attr_reader :name - # Compiled DI expression-language expression evaluated at probe-fire time. - # @return [Datadog::DI::EL::Expression] attr_reader :expr - # Per-expression capture-limit overrides, or nil when none are configured. - # @return [Datadog::DI::CaptureLimits, nil] attr_reader :limits end end diff --git a/lib/datadog/di/capture_expression_evaluator.rb b/lib/datadog/di/capture_expression_evaluator.rb index 956dd6fd35..c9036730c5 100644 --- a/lib/datadog/di/capture_expression_evaluator.rb +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -6,31 +6,9 @@ module Datadog module DI - # Evaluates a probe's capture expressions against a captured context - # and serializes their results into a shape suitable for the - # `captureExpressions` block of a snapshot. - # - # On per-expression evaluation/serialization failure, the expression's - # key is omitted from the output and an `{ expr: name, message: ... }` - # entry is appended to the returned `evaluation_errors` array. This - # matches the cross-tracer convention (Python, Java, .NET, Node.js, - # Go DI). - # - # On per-fire time-budget exhaustion, remaining expressions emit a - # stub `{ "notCapturedReason" => "timeout" }` entry, matching the - # Node.js DI shape. - # - # @api private class CaptureExpressionEvaluator TELEMETRY_NAMESPACE = "dynamic_instrumentation" - # @param settings [Datadog::Core::Configuration::Settings] - # @param serializer [Datadog::DI::Serializer] - # @param logger [Datadog::DI::Logger] - # @param telemetry [Datadog::Core::Telemetry::Component, nil] nil is - # legitimate -- Component.build accepts nil telemetry when DI is used - # outside a telemetry-configured environment, and Component threads - # the nil through to here. All telemetry calls below are nil-guarded. def initialize(settings:, serializer:, logger:, telemetry: nil) @settings = settings @serializer = serializer @@ -38,34 +16,14 @@ def initialize(settings:, serializer:, logger:, telemetry: nil) @telemetry = telemetry end - # Tracer settings; the `dynamic_instrumentation.max_time_to_serialize_ms` - # field is read on every #evaluate call to compute the per-fire deadline. - # @return [Datadog::Core::Configuration::Settings] attr_reader :settings - # Serializer used to convert evaluated expression values into the - # snapshot wire format. - # @return [Datadog::DI::Serializer] attr_reader :serializer - # Logger used for debug-level reporting of per-expression evaluation - # failures. - # @return [Datadog::DI::Logger] attr_reader :logger - # Telemetry component used to report capture-expression timeouts (inc) - # and per-expression evaluation exceptions (report). nil when DI was - # constructed without telemetry; all call sites guard with `&.`. - # @return [Datadog::Core::Telemetry::Component, nil] attr_reader :telemetry - # Evaluate +probe.capture_expressions+ against +context+. - # - # @param probe [Datadog::DI::Probe] - # @param context [Datadog::DI::Context] - # @return [Array(Hash, Array)] pair of - # - the `captureExpressions` block ({ name => serialized_value }) - # - the per-expression evaluation_errors array def evaluate(probe, context) budget_ns = settings.dynamic_instrumentation.max_time_to_serialize_ms * 1_000_000 deadline_ns = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) + budget_ns @@ -78,9 +36,6 @@ def evaluate(probe, context) if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :nanosecond) >= deadline_ns output[name] = {notCapturedReason: "timeout"} - # Counter is per-expression: a single probe fire with N expressions - # that all time out increments by N. The metric name reflects the - # unit ("expressions skipped"), not "fires timed out". telemetry&.inc(TELEMETRY_NAMESPACE, "capture_expressions_skipped_by_timeout", 1) next end diff --git a/lib/datadog/di/capture_limits.rb b/lib/datadog/di/capture_limits.rb index 05afacdf2c..0558446850 100644 --- a/lib/datadog/di/capture_limits.rb +++ b/lib/datadog/di/capture_limits.rb @@ -2,23 +2,7 @@ module Datadog module DI - # Per-expression capture limits override. - # - # Each field is independently optional. The +resolve+ class method - # walks a per-field fallback chain - # (expression -> probe -> language default) and returns a fully - # populated set of limits to pass to the serializer. - # - # @api private class CaptureLimits - # @param max_reference_depth [Integer, nil] override for object traversal - # depth. nil falls back to the next level in the resolve chain. - # @param max_collection_size [Integer, nil] override for Array/Hash size cap. - # nil falls back to the next level in the resolve chain. - # @param max_length [Integer, nil] override for String length cap. - # nil falls back to the next level in the resolve chain. - # @param max_field_count [Integer, nil] override for per-object attribute - # count cap. nil falls back to the next level in the resolve chain. def initialize(max_reference_depth: nil, max_collection_size: nil, max_length: nil, max_field_count: nil) @max_reference_depth = max_reference_depth @@ -27,43 +11,14 @@ def initialize(max_reference_depth: nil, max_collection_size: nil, @max_field_count = max_field_count end - # Object traversal depth override, or nil to defer to probe/settings. - # @return [Integer, nil] attr_reader :max_reference_depth - # Array/Hash size cap override, or nil to defer to probe/settings. - # @return [Integer, nil] attr_reader :max_collection_size - # String length cap override, or nil to defer to probe/settings. - # @return [Integer, nil] attr_reader :max_length - # Per-object attribute count cap override, or nil to defer to probe/settings. - # @return [Integer, nil] attr_reader :max_field_count - # Resolves effective limits for a capture expression by walking - # the per-field fallback chain: - # - # expr_limits.X ?? probe.max_capture_X ?? settings.dynamic_instrumentation.max_capture_X - # - # Resolved independently per field -- a missing field on the - # expression's limits falls through to the probe level, then to - # the language-wide default, without affecting the other fields. - # - # Returns a Hash with keys :depth, :collection_size, :length, - # :attribute_count to match the serializer's keyword arguments. - # - # @param expr_limits [Datadog::DI::CaptureLimits, nil] per-expression - # overrides, or nil to skip the expression level entirely. - # @param probe [Datadog::DI::Probe] probe carrying probe-level overrides - # (max_capture_depth, max_capture_collection_size, max_capture_string_length, - # max_capture_attribute_count). Any field may be nil. - # @param settings [Datadog::Core::Configuration::Settings] tracer settings - # providing the dynamic_instrumentation.max_capture_* fallback values. - # @return [Hash{Symbol => Integer}] fully resolved limits with keys - # :depth, :collection_size, :length, :attribute_count. def self.resolve(expr_limits:, probe:, settings:) di = settings.dynamic_instrumentation { diff --git a/lib/datadog/di/configuration.rb b/lib/datadog/di/configuration.rb index 9e3baecf58..e26c5fa609 100644 --- a/lib/datadog/di/configuration.rb +++ b/lib/datadog/di/configuration.rb @@ -134,16 +134,6 @@ def self.add_settings!(base) o.default 20 end - # Time budget (in milliseconds) for serialization work during - # probe fires. Today applied only to capture-expression - # evaluation; once the budget is exhausted, remaining capture - # expressions emit a stub entry with - # `notCapturedReason: "timeout"` in the snapshot. - # - # The env var is shared with .NET's whole-snapshot - # serialization budget. Default (200 ms) matches Python's - # hardcoded `CAPTURE_TIME_BUDGET` and .NET's - # `DefaultMaxSerializationTimeInMilliseconds`. option :max_time_to_serialize_ms do |o| o.type :int o.default 200 diff --git a/lib/datadog/di/context.rb b/lib/datadog/di/context.rb index 87981f7a75..9675300363 100644 --- a/lib/datadog/di/context.rb +++ b/lib/datadog/di/context.rb @@ -25,9 +25,6 @@ def initialize(probe:, settings:, serializer:, locals: nil, @path = path @caller_locations = caller_locations @serialized_entry_args = serialized_entry_args - # Capture-expression block + evaluation errors collected at the - # entry hook for an evaluate_at: :entry method probe. nil when the - # probe is exit-timed or has no capture expressions. @entry_capture_expressions = entry_capture_expressions @entry_capture_evaluation_errors = entry_capture_evaluation_errors @return_value = return_value @@ -51,12 +48,8 @@ def initialize(probe:, settings:, serializer:, locals: nil, attr_reader :serialized_entry_args - # Entry-time capture-expression block, for an evaluate_at: :entry - # method probe. Hash of name => serialized value, or nil. attr_reader :entry_capture_expressions - # Per-expression evaluation errors from the entry-time evaluation. - # Array of { expr:, message: } hashes, or nil. attr_reader :entry_capture_evaluation_errors # Return value for the method, for a method probe diff --git a/lib/datadog/di/el/compiler.rb b/lib/datadog/di/el/compiler.rb index b62c4e009b..5be7faee4b 100644 --- a/lib/datadog/di/el/compiler.rb +++ b/lib/datadog/di/el/compiler.rb @@ -78,12 +78,6 @@ def compile_partial(ast) # We could format to a string here but what if customer # has @duration as part of an expression and wants # to retain it as a number? - # - # At entry-time evaluation (evaluate_at: :entry) the duration - # is not yet known and context.duration is nil; guarding the - # multiplication makes @duration resolve to nil (undefined) - # instead of raising NoMethodError on nil * 1000, matching how - # @return and @exception resolve to nil in the entry context. "(context.duration && context.duration * 1000)" when '@exception' "context.exception" diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index 1a16a76ba0..aaa031076e 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -85,11 +85,6 @@ def initialize(settings, serializer, logger, code_tracker: nil, telemetry: nil) attr_reader :telemetry attr_reader :code_tracker - # Lazily-constructed CaptureExpressionEvaluator used for entry-time - # evaluation on method probes with evaluate_at: :entry. The notification - # builder owns a separate instance for exit-time evaluation; they share - # the same settings/serializer/logger/telemetry and produce identical - # output for an identical context. def capture_expression_evaluator @capture_expression_evaluator ||= CaptureExpressionEvaluator.new( settings: settings, serializer: serializer, logger: logger, telemetry: telemetry, @@ -535,14 +530,6 @@ def run_method_probe(args, kwargs, target_block, target_self, **probe.snapshot_serializer_limits(settings)) end - # Entry-time capture-expression evaluation. For probes with - # evaluate_at: :entry, evaluate against the pre-super scope - # (args/kwargs/self only -- @return/@duration/@exception are - # nil at this point) and store the result. The Context built - # below carries it through to the notification builder. - # Skipped when capture_snapshot is also set: the snapshot block - # wins at fire time, so any pre-computed capture-expression block - # would be discarded by ProbeNotificationBuilder. entry_capture_expressions = nil entry_capture_evaluation_errors = nil if probe.capture_expressions? && probe.evaluate_at == :entry && !probe.capture_snapshot? @@ -556,13 +543,6 @@ def run_method_probe(args, kwargs, target_block, target_self, capture_expression_evaluator.evaluate(probe, entry_context) rescue Exception => exc # standard:disable Lint/RescueException Datadog::DI.reraise_if_fatal(exc) - # Per-expression StandardError is caught inside the - # evaluator; reaching this rescue means a non-evaluator - # failure (e.g. building the entry Context or combining - # args raised). Mirror the condition-evaluation pattern - # above: surface in tests via propagate_all_exceptions, - # otherwise log + telemetry and continue with no entry - # block stashed. raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions logger.debug { "di: error evaluating entry-time capture expressions: #{exc.class}: #{exc.message}" } @@ -630,13 +610,6 @@ def run_method_probe(args, kwargs, target_block, target_self, caller_locs = method_frame + (caller_locations(2) || []) # TODO capture arguments at exit - # Method-probe capture expressions need access to args/kwargs by - # name (arg1, arg2, kwarg keys). Populate locals from the live - # arg references when the probe has capture expressions; values - # may reflect in-place mutation by the method body, matching the - # return-time scope semantics. - # Skip when capture_snapshot is also set -- the snapshot wins at - # build time, so the combined hash would be discarded. capture_expression_locals = if probe.capture_expressions? && !probe.capture_snapshot? serializer.combine_args(args, kwargs, target_self) end diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index 9ea99dfd72..dbc3700604 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -34,11 +34,6 @@ module DI class Probe KNOWN_TYPES = %i[log].freeze - # Permitted values for the +evaluate_at+ constructor argument. - # +:exit+ is the default applied when +nil+ is passed (this matches the - # libdatadog ProbeCommon JSON-parse default of EvaluateAt::Exit, which - # PHP shares; Python's Snapshot._timing also resolves DEFAULT to EXIT - # for log probes). EVALUATE_AT_VALUES = %i[entry exit].freeze def initialize(id:, type:, @@ -95,11 +90,6 @@ def initialize(id:, type:, @max_capture_collection_size = max_capture_collection_size @max_capture_string_length = max_capture_string_length @capture_expressions = capture_expressions || [] - # Per-expression evaluation timing on method probes. nil from the - # RC payload (or the constructor default) coerces to +:exit+, matching - # the libdatadog ProbeCommon JSON-parse default (EvaluateAt::Exit). - # Unknown symbols are rejected at construction. Line probes ignore - # this value (single firing point at the TracePoint callback). evaluate_at = :exit if evaluate_at.nil? unless EVALUATE_AT_VALUES.include?(evaluate_at) raise ArgumentError, "Unknown evaluate_at value: #{evaluate_at.inspect} (expected one of #{EVALUATE_AT_VALUES.inspect})" @@ -107,10 +97,6 @@ def initialize(id:, type:, @evaluate_at = evaluate_at @condition = condition - # Capture-expression probes are charged against the snapshot rate-limit - # bucket (1/sec default), matching Python/.NET/Go DI. They are not - # treated as cheap log probes (5000/sec) because evaluating arbitrary - # user-authored expressions has snapshot-class cost. @rate_limit = rate_limit || ((@capture_snapshot || !@capture_expressions.empty?) ? 1 : 5000) @rate_limiter = Datadog::Core::TokenBucket.new(@rate_limit) @@ -148,25 +134,12 @@ def initialize(id:, type:, # the global default will be used. attr_reader :max_capture_attribute_count - # Configured maximum collection size. Can be nil in which case the - # global default will be used. attr_reader :max_capture_collection_size - # Configured maximum string length. Can be nil in which case the - # global default will be used. attr_reader :max_capture_string_length - # Capture expressions attached to this probe. Empty array when no - # capture expressions are configured. - # - # @return [Array] attr_reader :capture_expressions - # Per-expression evaluation timing for method probes. One of - # +:entry+ / +:exit+. Defaults to +:exit+ (matching libdatadog's - # ProbeCommon JSON-parse default). Ignored by line probes. - # - # @return [Symbol] attr_reader :evaluate_at # Rate limit in effect, in invocations per second. Always present. @@ -189,21 +162,10 @@ def capture_snapshot? @capture_snapshot end - # Returns whether the probe has any capture expressions configured. def capture_expressions? !@capture_expressions.empty? end - # Returns the four capture-limit keyword arguments for snapshot - # serialization, applying the probe-level override for each field and - # falling back to the DI settings default when the probe-level value - # is nil. The result is intended to be splatted into - # +Serializer#serialize_value+, +serialize_args+, or +serialize_vars+. - # - # @param settings [Datadog::Core::Configuration::Settings] tracer settings - # providing the dynamic_instrumentation.max_capture_* fallback values. - # @return [Hash{Symbol => Integer}] hash with keys :depth, :attribute_count, - # :length, :collection_size -- all values are Integers (no nils). def snapshot_serializer_limits(settings) di = settings.dynamic_instrumentation { diff --git a/lib/datadog/di/probe_builder.rb b/lib/datadog/di/probe_builder.rb index 32bce2ecdc..ecfb3a0e09 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -29,12 +29,8 @@ module ProbeBuilder module_function - # Backend JSON schema constraint on capture-expression names. CAPTURE_EXPRESSION_NAME_PATTERN = /\A[a-zA-Z0-9_?]+\z/ - # Permitted values for the RC payload's `evaluateAt` field. The backend - # schema also accepts "DEFAULT" for Java compatibility; we normalize that - # to :exit at parse time to match the libdatadog default. EVALUATE_AT_STRINGS = { "ENTRY" => :entry, "EXIT" => :exit, @@ -55,11 +51,6 @@ def build_from_remote_config(config, logger:) capture_expressions = build_capture_expressions(config['captureExpressions']) capture_expressions = dedup_capture_expressions(capture_expressions, config["id"], logger) if !!config["captureSnapshot"] && !capture_expressions.empty? - # When both captureSnapshot and captureExpressions are set on the - # same probe, the runtime emits the snapshot block and silently - # drops capture-expression values (snapshot wins), matching - # Python/Java/Go DI. Logged at debug to make the choice observable - # without spamming operator logs. logger.debug do "di: probe #{config["id"]}: captureSnapshot=true wins over captureExpressions (n=#{capture_expressions.size})" end @@ -91,17 +82,6 @@ def build_from_remote_config(config, logger:) raise ArgumentError, "Malformed remote configuration entry for probe: #{exc.class}: #{exc.message}: #{config}" end - # Parses the `captureExpressions` block of a remote-config probe payload - # into compiled CaptureExpression instances. - # - # @param raw [Array, nil] raw `captureExpressions` value from the - # remote-config payload. nil is treated as "no expressions" (returns []). - # @return [Array] one per entry; empty - # array when raw is nil or empty. - # @raise [ArgumentError] when raw is non-nil and not an Array, when any - # entry is not a Hash, when an entry's name is missing or fails the - # CAPTURE_EXPRESSION_NAME_PATTERN charset check, or when the `expr` - # field is missing/malformed. def build_capture_expressions(raw) return [] if raw.nil? unless Array === raw @@ -127,20 +107,6 @@ def build_capture_expressions(raw) end end - # Collapses capture expressions that share a name, keeping the last - # occurrence for each name. Duplicate names are permitted on the wire and - # are forwarded unchanged. The resulting last-wins output matches - # Python/Java/Node.js/PHP; unlike those tracers, which evaluate every - # entry and let a later value overwrite an earlier one at snapshot time, - # Ruby resolves duplicates here at parse time so a discarded duplicate is - # never evaluated. A collapse is logged at debug so it stays observable. - # - # @param capture_expressions [Array] - # parsed capture expressions, in payload order. - # @param probe_id [String, nil] probe id for the diagnostic log line. - # @param logger [Datadog::DI::Logger] injected logger. - # @return [Array] one per distinct name, - # with the last occurrence retained for each. def dedup_capture_expressions(capture_expressions, probe_id, logger) return capture_expressions if capture_expressions.size < 2 by_name = {} @@ -154,14 +120,6 @@ def dedup_capture_expressions(capture_expressions, probe_id, logger) by_name.values end - # Parses a per-expression `capture` block of a remote-config probe payload - # into a CaptureLimits instance. - # - # @param raw [Hash, nil] raw per-expression `capture` value. nil means - # "no per-expression overrides" (returns nil). - # @return [Datadog::DI::CaptureLimits, nil] CaptureLimits when raw is a - # Hash; nil when raw is nil. - # @raise [ArgumentError] when raw is non-nil and not a Hash. def build_capture_limits(raw) return nil if raw.nil? unless Hash === raw @@ -175,18 +133,6 @@ def build_capture_limits(raw) ) end - # Parses the RC payload's `evaluateAt` string into the - # +Datadog::DI::Probe+ symbol form. "ENTRY" -> :entry, "EXIT" -> :exit, - # "DEFAULT" -> :exit (Java sends this; libdatadog also treats it as - # Exit). Absent or unrecognized values coerce to :exit and emit a - # debug log; the runtime never raises on an unknown evaluateAt because - # such payloads should still install as conventional EXIT-timed probes. - # - # @param raw [String, nil] raw `evaluateAt` value from the RC payload. - # @param probe_id [String, nil] probe id for the diagnostic log line. - # @param logger [Datadog::DI::Logger] injected logger for the diagnostic - # log line emitted on an unrecognized value. - # @return [Symbol] :entry or :exit. def parse_evaluate_at(raw, probe_id, logger) return :exit if raw.nil? EVALUATE_AT_STRINGS[raw] || begin diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index b5e0d32f46..d5e0ca4cbf 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -11,15 +11,6 @@ module DI # # @api private class ProbeNotificationBuilder - # @param settings [Datadog::Core::Configuration::Settings] tracer settings; - # read at payload-build time for service/env/version/tags fields. - # @param serializer [Datadog::DI::Serializer] serializer used for snapshot - # values (captures, return, throwable). - # @param logger [Datadog::DI::Logger] logger forwarded to the internal - # CaptureExpressionEvaluator for per-expression evaluation failures. - # @param telemetry [Datadog::Core::Telemetry::Component, nil] telemetry - # forwarded to the internal CaptureExpressionEvaluator. nil when DI was - # constructed without telemetry (Component.build allows this). def initialize(settings, serializer, logger, telemetry: nil) @settings = settings @serializer = serializer @@ -30,27 +21,14 @@ def initialize(settings, serializer, logger, telemetry: nil) ) end - # Tracer settings; read at every build_* call. - # @return [Datadog::Core::Configuration::Settings] attr_reader :settings - # Serializer used to convert captured values into snapshot wire format. - # @return [Datadog::DI::Serializer] attr_reader :serializer - # Logger; passed through to CaptureExpressionEvaluator at construction. - # @return [Datadog::DI::Logger] attr_reader :logger - # Telemetry; passed through to CaptureExpressionEvaluator at construction. - # nil when DI was constructed without telemetry. - # @return [Datadog::Core::Telemetry::Component, nil] attr_reader :telemetry - # Sub-component that evaluates probe.capture_expressions during - # build_snapshot when probe.capture_expressions? is true. Constructed - # in the initializer alongside the other collaborators. - # @return [Datadog::DI::CaptureExpressionEvaluator] attr_reader :capture_expression_evaluator def build_received(probe) @@ -100,11 +78,6 @@ def build_snapshot(context) raise ArgumentError, "Asked to build snapshot with snapshot capture but target_self is nil" end - # Mutual exclusion: capture_snapshot wins at fire time when both - # captureSnapshot and captureExpressions are set on the same probe, - # matching Python/Java/Go DI. The capture-expression values are - # silently dropped in that case; the user-visible mutual exclusion - # is also logged at parse time in ProbeBuilder. # TODO also verify that non-capturing probe does not pass # snapshot or vars/args into this method capture_expression_evaluation_errors = [] @@ -135,13 +108,6 @@ def build_snapshot(context) } end elsif probe.capture_expressions? - # Per-expression evaluation timing on method probes is single-eval, - # honoring probe.evaluate_at (default :exit), matching Python / - # .NET / PHP. evaluate_at: :entry block was evaluated at the entry - # hook (Instrumenter#hook_method) against the pre-super scope; we - # just consume the stashed result. evaluate_at: :exit (default) - # evaluates here, at exit, against the full exit-time scope. - # Line probes ignore evaluate_at and always emit under captures.lines. if probe.method? if probe.evaluate_at == :entry captured_block = context.entry_capture_expressions || {} @@ -175,9 +141,6 @@ def build_snapshot(context) if segments = probe.template_segments message, evaluation_errors = evaluate_template(segments, context) end - # Per-expression evaluation errors are merged into the snapshot's - # top-level evaluationErrors array alongside template/condition errors, - # matching the cross-tracer convention. evaluation_errors.concat(capture_expression_evaluation_errors) build_snapshot_base(context, evaluation_errors: evaluation_errors, message: message, diff --git a/lib/datadog/di/serializer.rb b/lib/datadog/di/serializer.rb index e60c8f80ea..32d6e05a82 100644 --- a/lib/datadog/di/serializer.rb +++ b/lib/datadog/di/serializer.rb @@ -129,18 +129,6 @@ def combine_args(args, kwargs, target_self) # Instance variables are technically a hash just like kwargs, # we take them as a separate parameter to avoid a hash merge # in upstream code. - # - # @param args [Array] positional arguments captured at the call site. - # @param kwargs [Hash] keyword arguments captured at the call site. - # @param target_self [Object] the receiver of the instrumented method - # call; its instance variables are folded into the output. - # @param depth [Integer] object traversal depth cap. - # @param attribute_count [Integer] per-object attribute count cap. - # @param length [Integer, nil] String length cap; nil leaves it - # unspecified and lets serialize_value fall back to settings. - # @param collection_size [Integer, nil] Array/Hash size cap; nil leaves - # it unspecified and lets serialize_value fall back to settings. - # @return [Hash{Symbol => Hash}] serialized argument map. def serialize_args(args, kwargs, target_self, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, @@ -155,15 +143,6 @@ def serialize_args(args, kwargs, target_self, # # These are normally local variables that exist on a particular line # of executed code. - # - # @param vars [Hash{Symbol => Object}] variable-name => value map. - # @param depth [Integer] object traversal depth cap. - # @param attribute_count [Integer] per-object attribute count cap. - # @param length [Integer, nil] String length cap; nil leaves it - # unspecified and lets serialize_value fall back to settings. - # @param collection_size [Integer, nil] Array/Hash size cap; nil leaves - # it unspecified and lets serialize_value fall back to settings. - # @return [Hash{Symbol => Hash}] serialized variable map. def serialize_vars(vars, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: settings.dynamic_instrumentation.max_capture_attribute_count, @@ -187,11 +166,6 @@ def serialize_vars(vars, # (integers, strings, arrays, hashes). # # Respects string length, collection size and traversal depth limits. - # - # +length+ and +collection_size+ accept nil to mean "use the global - # default setting". They are threaded through to recursive calls so - # callers (e.g. the capture-expression evaluator) can override them - # per evaluation without re-routing through the global settings. def serialize_value(value, name: nil, depth: settings.dynamic_instrumentation.max_capture_depth, attribute_count: nil, @@ -232,10 +206,6 @@ def serialize_value(value, name: nil, if condition_result serializer_proc = entry.fetch(:proc) - # Custom serializers do not yet accept length / collection_size; - # they receive the standard depth only. This matches the - # pre-capture-expressions behavior so existing custom - # serializers continue to work. return serializer_proc.call(self, value, name: nil, depth: depth) end end diff --git a/spec/datadog/di/capture_expression_evaluator_spec.rb b/spec/datadog/di/capture_expression_evaluator_spec.rb index 07c379356e..0e08a43d67 100644 --- a/spec/datadog/di/capture_expression_evaluator_spec.rb +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -70,8 +70,6 @@ def compile_expression(dsl_string, json) end context "expression evaluation raises" do - # len() of a non-Array/String/Hash raises ExpressionEvaluationError. - # The locals do not have :badvar, so the ref resolves to nil, and len(nil) raises. let(:probe) do Datadog::DI::Probe.new( id: "p1", type: :log, type_name: "F", method_name: "m", @@ -106,10 +104,6 @@ def compile_expression(dsl_string, json) end context "expression evaluation raises a non-StandardError" do - # NotImplementedError is not a StandardError, so a plain `rescue => e` - # would let it escape into the customer method. The evaluator rescues - # Exception and re-raises only fatal exceptions, so a non-fatal - # non-StandardError is recorded as an evaluation error like any other. let(:expr) { instance_double(Datadog::DI::EL::Expression) } let(:probe) do @@ -135,8 +129,6 @@ def compile_expression(dsl_string, json) end context "expression evaluation raises a fatal exception" do - # Fatal exceptions (process teardown / OOM) must never be swallowed by - # the catch-all rescue; reraise_if_fatal propagates them out of #evaluate. let(:expr) { instance_double(Datadog::DI::EL::Expression) } let(:probe) do @@ -219,12 +211,6 @@ def compile_expression(dsl_string, json) context "time budget exhausted mid-loop after some expressions have evaluated" do before do allow(di_settings).to receive(:max_time_to_serialize_ms).and_return(100) - # Stub the clock so the deadline calculation sees t=0, the first - # iteration's check sees t=0 (under deadline of 100ms in ns), and - # the second iteration's check sees t=200ms in ns (past deadline). - # Targets only the :nanosecond variant the evaluator uses; other - # callers (Time.now via :float_second, etc.) fall through to the - # real implementation. clock_calls = 0 clock_returns = [0, 0, 200_000_000] allow(::Process).to receive(:clock_gettime).and_wrap_original do |original, *args| @@ -279,8 +265,6 @@ def compile_expression(dsl_string, json) ) end - # The depth value passed to the serializer is what we are asserting on - # rather than the resulting JSON shape. it "passes the expression's depth into the serializer" do expect(serializer).to receive(:serialize_value).with(42, hash_including(depth: 5, attribute_count: 10)).and_call_original evaluator.evaluate(probe, context) diff --git a/spec/datadog/di/integration/instrumentation_spec.rb b/spec/datadog/di/integration/instrumentation_spec.rb index 6011c6a351..5e958bb3d5 100644 --- a/spec/datadog/di/integration/instrumentation_spec.rb +++ b/spec/datadog/di/integration/instrumentation_spec.rb @@ -595,7 +595,6 @@ def run_test expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( "arg1" => {type: 'Integer', value: '7'}, ) - # Capture expressions are evaluated once at return; no entry block is emitted. expect(captures).not_to have_key(:entry) end end @@ -627,16 +626,13 @@ def run_test greeting = +'hello world' InstrumentationSpecTestClass.new.mutating_method(greeting) - # Confirm the method mutated the arg. expect(greeting).to eq('bye world') component.probe_notifier_worker.flush captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) - # Entry-time evaluation sees the original value, not the post-mutation one. expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( "greeting_at_entry" => {type: 'String', value: 'hello world'}, ) - # evaluateAt: :entry emits only the entry block; no return block. expect(captures).not_to have_key(:return) end end @@ -646,10 +642,6 @@ def run_test Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end - # At entry time the duration is not yet known (context.duration is nil). - # @duration must resolve to nil (undefined) rather than raising, so the - # key is captured as a null value instead of being dropped with an - # evaluation error. let(:probe_spec) do { id: '1234', @@ -675,12 +667,10 @@ def run_test snapshot = payload.fetch(:debugger).fetch(:snapshot) captures = snapshot.fetch(:captures) - # The key is present with a null value, not omitted. expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( "duration_at_entry" => {type: 'NilClass', isNull: true}, ) expect(captures).not_to have_key(:return) - # No evaluation error is recorded for the expression. expect(snapshot.fetch(:evaluationErrors, [])).to be_empty end end @@ -714,7 +704,6 @@ def run_test component.probe_notifier_worker.flush captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) - # Exit-time evaluation sees the post-mutation value. expect(captures.fetch(:return).fetch(:captureExpressions)).to eq( "greeting_at_exit" => {type: 'String', value: 'bye world'}, ) @@ -802,9 +791,6 @@ def run_test Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end - # len(undefined) -- `undefined` resolves to nil at the EL layer, and - # len(nil) raises ExpressionEvaluationError, which the evaluator - # catches and reports via evaluationErrors. let(:probe_spec) do { id: '1234', @@ -859,24 +845,18 @@ def run_test component.probe_notifier_worker.flush captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) - # Entry arg passes through serialize_args with the probe-level limit. expect(captures.fetch(:entry).fetch(:arguments).fetch(:arg1)).to include( type: 'String', value: 'hell', truncated: true, ) - # @return passes through serialize_value with the probe-level limit. expect(captures.fetch(:return).fetch(:arguments).fetch(:@return)).to include( type: 'String', value: 'bye ', truncated: true, ) - # self is serialized with the probe-level limit so its ivar string is also truncated. ivar = captures.fetch(:return).fetch(:arguments).fetch(:self).fetch(:fields).fetch(:@ivar) expect(ivar).to include(type: 'String', value: 'star', truncated: true) end end context 'with snapshot capture and probe-level maxCollectionSize override' do - # Verifies the same plumbing as the maxLength test, for the - # maxCollectionSize override that was previously silently dropped - # before flowing through Probe#snapshot_serializer_limits. let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, type_name: 'InstrumentationSpecTestClass', method_name: 'collection_method', @@ -898,15 +878,12 @@ def run_test component.probe_notifier_worker.flush captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) - # Entry arg array passes through serialize_args; only first 2 elements captured. arg1 = captures.fetch(:entry).fetch(:arguments).fetch(:arg1) expect(arg1).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) expect(arg1.fetch(:elements).size).to eq(2) - # @return array passes through serialize_value with the probe-level limit. ret = captures.fetch(:return).fetch(:arguments).fetch(:@return) expect(ret).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) expect(ret.fetch(:elements).size).to eq(2) - # self serialization recurses with the probe-level limit so the ivar array is also truncated. ivar = captures.fetch(:return).fetch(:arguments).fetch(:self).fetch(:fields).fetch(:@ivar) expect(ivar).to include(type: 'Array', notCapturedReason: 'collectionSize', size: 5) expect(ivar.fetch(:elements).size).to eq(2) @@ -1249,7 +1226,6 @@ def run_test Datadog::DI::ProbeBuilder.build_from_remote_config(JSON.parse(probe_spec.to_json), logger: logger) end - # Line 40 of the test target reads `a * 2` after `a = 21` on line 33. let(:probe_spec) do { id: '1234', @@ -1529,11 +1505,6 @@ def run_test end context 'with probe-level maxCollectionSize override on line-probe locals' do - # Verifies probe-level capture-limit overrides flow through to - # serialize_vars on the line-probe locals path. Previously - # maxLength / maxCollectionSize were silently dropped here; this - # asserts that probe.snapshot_serializer_limits feeds - # Context#serialized_locals. let(:probe) do Datadog::DI::Probe.new(id: "1234", type: :log, file: 'instrumentation_integration_test_class.rb', line_no: 40, @@ -1558,7 +1529,6 @@ def run_test expect(redacted_local).to include( type: 'Hash', notCapturedReason: 'collectionSize', size: 2, ) - # Only the first entry (:b => 33) made it under the size cap. expect(redacted_local.fetch(:entries).size).to eq(1) end end diff --git a/spec/datadog/di/probe_notification_builder_spec.rb b/spec/datadog/di/probe_notification_builder_spec.rb index b106319db8..ff49c95447 100644 --- a/spec/datadog/di/probe_notification_builder_spec.rb +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -849,9 +849,6 @@ end it 'consumes pre-computed entry-time block; does not re-evaluate against exit scope' do - # context.locals contains {x: 42}, but the stashed entry block has - # {"x" => 7} -- confirming the builder uses the pre-computed block and - # does not run the evaluator against the exit-time scope. payload = builder.build_snapshot(context) expect(payload[:debugger][:snapshot][:captures][:entry][:captureExpressions]["x"][:value]).to eq("7") end @@ -882,7 +879,6 @@ context 'evaluation error in a capture expression' do let(:failing_expr) do - # len(badvar) -- badvar is not in locals, resolves to nil, len(nil) raises compiled = Datadog::DI::EL::Compiler.new.compile({"len" => {"ref" => "badvar"}}) Datadog::DI::EL::Expression.new("len(badvar)", compiled) end From 427a214735ed149fcdcad70cb04f3d7a5768adc0 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 13 Jul 2026 16:27:37 -0400 Subject: [PATCH 38/42] DI: drop .NET cross-tracer reference and timeout-stub detail from MAX_TIME_TO_SERIALIZE doc --- docs/GettingStarted.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index 3ae9083cbe..f2b5500b9f 100644 --- a/docs/GettingStarted.md +++ b/docs/GettingStarted.md @@ -2958,7 +2958,7 @@ Dynamic Instrumentation allows you to add log probes, metric probes, and span pr | `DD_DYNAMIC_INSTRUMENTATION_REDACTED_IDENTIFIERS` | `Array` | Comma-separated list of variable/key names to redact in addition to the built-in list. Names are normalized (underscores, dashes, `@`, `$` removed) and matched case-insensitively. | `[]` | | `DD_DYNAMIC_INSTRUMENTATION_REDACTION_EXCLUDED_IDENTIFIERS` | `Array` | Comma-separated list of variable/key names to exclude from the built-in redaction list, allowing their values to be captured. | `[]` | | `DD_DYNAMIC_INSTRUMENTATION_REDACTED_TYPES` | `Array` | Comma-separated list of class names whose values will be redacted. Suffix with `*` for wildcard matching (for example, `Foo*` redacts `Foo`, `FooBar`, `Foo::Bar`). | `[]` | -| `DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE` | `Integer` | Time budget (in milliseconds) per probe fire for capture-expression evaluation. Once exhausted, remaining capture expressions emit a `notCapturedReason: "timeout"` stub in the snapshot. Shared with the .NET tracer's whole-snapshot serialization budget. | `200` | +| `DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE` | `Integer` | Time budget (in milliseconds) per probe fire for capture-expression evaluation. | `200` | Alternatively, set DI parameters inside a `Datadog.configure` block: From 90a64e12eae7f6fb8e35e235641a2a34976d59a1 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 13 Jul 2026 16:27:57 -0400 Subject: [PATCH 39/42] DI: drop Java cross-tracer reference from probe_builder_spec context --- spec/datadog/di/probe_builder_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index 4904b0246c..9bd1f8d54b 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -395,7 +395,7 @@ end end - context "\"DEFAULT\" (Java sends this)" do + context "\"DEFAULT\"" do let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "DEFAULT") } it "maps to :exit (matching libdatadog default)" do From 1ef3cfb1cfde37d81575a6df1fb36576f70e1270 Mon Sep 17 00:00:00 2001 From: ddsign Date: Mon, 13 Jul 2026 16:28:38 -0400 Subject: [PATCH 40/42] DI: drop libdatadog reference from probe_builder_spec example description --- spec/datadog/di/probe_builder_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/datadog/di/probe_builder_spec.rb b/spec/datadog/di/probe_builder_spec.rb index 9bd1f8d54b..c60a9b6927 100644 --- a/spec/datadog/di/probe_builder_spec.rb +++ b/spec/datadog/di/probe_builder_spec.rb @@ -398,7 +398,7 @@ context "\"DEFAULT\"" do let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "DEFAULT") } - it "maps to :exit (matching libdatadog default)" do + it "maps to :exit" do expect(probe.evaluate_at).to eq(:exit) end end From 0111853bcc752a20810a4153c13b412e9edd6ed6 Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 14 Jul 2026 09:55:17 -0400 Subject: [PATCH 41/42] Extract capture-expression predicates and simplify limit resolution on Probe Address review comments (extract conditions as methods on `probe`; `snapshot_serializer_limits` flagged as a delegation candidate): - lib/datadog/di/probe.rb: add `evaluate_at_entry?`, `capture_expressions_only?`, and `capture_entry_expressions?`; `snapshot_serializer_limits` now delegates to `CaptureLimits.resolve`, removing the duplicated probe->settings fallback chain. - lib/datadog/di/instrumenter.rb (535, 613): call the new predicates. - lib/datadog/di/probe_notification_builder.rb (112): use `evaluate_at_entry?`. - sig/datadog/di/probe.rbs: signatures for the three predicates. - spec/datadog/di/probe_spec.rb: coverage for the three predicates. Behavior-preserving: the predicate bodies are the identical boolean expressions, and CaptureLimits.resolve(expr_limits: nil, ...) returns the identical limits hash. Verified locally: probe_spec (44), probe_notification_builder_spec (30), instrumenter_spec (112) all pass, 0 failures. --- lib/datadog/di/instrumenter.rb | 4 +- lib/datadog/di/probe.rb | 23 +++++--- lib/datadog/di/probe_notification_builder.rb | 2 +- sig/datadog/di/probe.rbs | 3 ++ spec/datadog/di/probe_spec.rb | 56 ++++++++++++++++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index aaa031076e..d57c7aac84 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -532,7 +532,7 @@ def run_method_probe(args, kwargs, target_block, target_self, entry_capture_expressions = nil entry_capture_evaluation_errors = nil - if probe.capture_expressions? && probe.evaluate_at == :entry && !probe.capture_snapshot? + if probe.capture_entry_expressions? begin entry_context = Context.new( probe: probe, settings: settings, serializer: serializer, @@ -610,7 +610,7 @@ def run_method_probe(args, kwargs, target_block, target_self, caller_locs = method_frame + (caller_locations(2) || []) # TODO capture arguments at exit - capture_expression_locals = if probe.capture_expressions? && !probe.capture_snapshot? + capture_expression_locals = if probe.capture_expressions_only? serializer.combine_args(args, kwargs, target_self) end context = Context.new(locals: capture_expression_locals, target_self: target_self, diff --git a/lib/datadog/di/probe.rb b/lib/datadog/di/probe.rb index dbc3700604..9a358e0f9b 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -166,14 +166,23 @@ def capture_expressions? !@capture_expressions.empty? end + def evaluate_at_entry? + evaluate_at == :entry + end + + # Capture-expression mode: the probe captures expressions but not a full + # snapshot. Snapshot capture serializes its own values, so expression + # capture only runs when a snapshot is not being taken. + def capture_expressions_only? + capture_expressions? && !capture_snapshot? + end + + def capture_entry_expressions? + capture_expressions_only? && evaluate_at_entry? + end + def snapshot_serializer_limits(settings) - di = settings.dynamic_instrumentation - { - depth: max_capture_depth || di.max_capture_depth, - attribute_count: max_capture_attribute_count || di.max_capture_attribute_count, - length: max_capture_string_length || di.max_capture_string_length, - collection_size: max_capture_collection_size || di.max_capture_collection_size, - } + CaptureLimits.resolve(expr_limits: nil, probe: self, settings: settings) end # Returns whether the probe is a line probe. diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb index 16e22a54c1..f39170bca9 100644 --- a/lib/datadog/di/probe_notification_builder.rb +++ b/lib/datadog/di/probe_notification_builder.rb @@ -109,7 +109,7 @@ def build_snapshot(context) end elsif probe.capture_expressions? if probe.method? - if probe.evaluate_at == :entry + if probe.evaluate_at_entry? captured_block = context.entry_capture_expressions || {} capture_expression_evaluation_errors = context.entry_capture_evaluation_errors || [] { diff --git a/sig/datadog/di/probe.rbs b/sig/datadog/di/probe.rbs index 746a9234c1..578cf6565f 100644 --- a/sig/datadog/di/probe.rbs +++ b/sig/datadog/di/probe.rbs @@ -75,6 +75,9 @@ module Datadog def capture_snapshot?: () -> bool def capture_expressions?: () -> bool + def evaluate_at_entry?: () -> bool + def capture_expressions_only?: () -> bool + def capture_entry_expressions?: () -> bool def snapshot_serializer_limits: (Datadog::Core::Configuration::Settings settings) -> Hash[Symbol, Integer] def line?: () -> bool def method?: () -> bool diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 3dffb1fd34..64a00055a5 100644 --- a/spec/datadog/di/probe_spec.rb +++ b/spec/datadog/di/probe_spec.rb @@ -260,6 +260,62 @@ end end + describe "capture-expression predicates" do + let(:capture_expression) do + Datadog::DI::CaptureExpression.new(name: "x", expr: instance_double(Datadog::DI::EL::Expression)) + end + + def build_probe(**opts) + described_class.new(id: "42", type: :log, type_name: "Foo", method_name: "bar", **opts) + end + + describe "#evaluate_at_entry?" do + it "is true when evaluate_at is :entry" do + expect(build_probe(evaluate_at: :entry).evaluate_at_entry?).to be true + end + + it "is false when evaluate_at is :exit" do + expect(build_probe(evaluate_at: :exit).evaluate_at_entry?).to be false + end + + it "is false by default (coerces to :exit)" do + expect(build_probe.evaluate_at_entry?).to be false + end + end + + describe "#capture_expressions_only?" do + it "is true with capture expressions and no snapshot" do + expect(build_probe(capture_expressions: [capture_expression], capture_snapshot: false).capture_expressions_only?).to be true + end + + it "is false when capturing a snapshot" do + expect(build_probe(capture_expressions: [capture_expression], capture_snapshot: true).capture_expressions_only?).to be false + end + + it "is false without capture expressions" do + expect(build_probe(capture_expressions: []).capture_expressions_only?).to be false + end + end + + describe "#capture_entry_expressions?" do + it "is true with entry-evaluated capture expressions and no snapshot" do + expect(build_probe(capture_expressions: [capture_expression], evaluate_at: :entry, capture_snapshot: false).capture_entry_expressions?).to be true + end + + it "is false when evaluated at exit" do + expect(build_probe(capture_expressions: [capture_expression], evaluate_at: :exit, capture_snapshot: false).capture_entry_expressions?).to be false + end + + it "is false when capturing a snapshot" do + expect(build_probe(capture_expressions: [capture_expression], evaluate_at: :entry, capture_snapshot: true).capture_entry_expressions?).to be false + end + + it "is false without capture expressions" do + expect(build_probe(capture_expressions: [], evaluate_at: :entry).capture_entry_expressions?).to be false + end + end + end + describe "evaluate_at" do context "omitted" do let(:probe) do From c19b846e2d3003fad0934cf559a861dbef0ab74d Mon Sep 17 00:00:00 2001 From: ddsign Date: Tue, 14 Jul 2026 09:55:25 -0400 Subject: [PATCH 42/42] DI RBS: use `any` alias for diff-added arbitrary-value untyped Address review comment (use `any` instead of `untyped`): switch the untyped values introduced by this PR in the DI signature subtree to the repo's `any` alias (`type any = untyped`, sig/datadog.rbs), documenting "accepts any type". Scoped to the signatures this PR added: - sig/datadog/di/context.rbs: entry_capture_expressions (ivar, ctor param, reader) - sig/datadog/di/serializer.rbs: serialize_args, serialize_vars, serialize_value - sig/datadog/di/probe_builder.rbs: build_from_remote_config, build_capture_expressions, build_capture_limits - sig/datadog/di/capture_expression_evaluator.rbs: evaluate `any` is identical to `untyped` (alias), so no type-checking behavior changes. Remaining pre-existing DI/symdb untyped are converted in a follow-up PR. --- sig/datadog/di/capture_expression_evaluator.rbs | 2 +- sig/datadog/di/context.rbs | 6 +++--- sig/datadog/di/probe_builder.rbs | 6 +++--- sig/datadog/di/serializer.rbs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sig/datadog/di/capture_expression_evaluator.rbs b/sig/datadog/di/capture_expression_evaluator.rbs index 175eb438c6..91aee1d8f4 100644 --- a/sig/datadog/di/capture_expression_evaluator.rbs +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -15,7 +15,7 @@ module Datadog attr_reader logger: DI::Logger attr_reader telemetry: Core::Telemetry::Component? - def evaluate: (Probe probe, Context context) -> [Hash[String, untyped], Array[Hash[Symbol, String]]] + def evaluate: (Probe probe, Context context) -> [Hash[String, any], Array[Hash[Symbol, String]]] end end end diff --git a/sig/datadog/di/context.rbs b/sig/datadog/di/context.rbs index 9d02fd6ffc..d0e29e7762 100644 --- a/sig/datadog/di/context.rbs +++ b/sig/datadog/di/context.rbs @@ -17,7 +17,7 @@ module Datadog @serialized_entry_args: Hash[Symbol, untyped]? - @entry_capture_expressions: Hash[String, untyped]? + @entry_capture_expressions: Hash[String, any]? @entry_capture_evaluation_errors: Array[Hash[Symbol, String]]? @return_value: untyped @@ -26,7 +26,7 @@ module Datadog @exception: Exception? - def initialize: (probe: Probe, settings: Datadog::Core::Configuration::Settings, serializer: Serializer, ?locals: Hash[Symbol, untyped]?, ?target_self: untyped?, ?path: String?, ?caller_locations: Array[Thread::Backtrace::Location]?, ?serialized_entry_args: Hash[Symbol, untyped]?, ?entry_capture_expressions: Hash[String, untyped]?, ?entry_capture_evaluation_errors: Array[Hash[Symbol, String]]?, ?return_value: untyped?, ?duration: Float?, ?exception: Exception?) -> void + def initialize: (probe: Probe, settings: Datadog::Core::Configuration::Settings, serializer: Serializer, ?locals: Hash[Symbol, untyped]?, ?target_self: untyped?, ?path: String?, ?caller_locations: Array[Thread::Backtrace::Location]?, ?serialized_entry_args: Hash[Symbol, untyped]?, ?entry_capture_expressions: Hash[String, any]?, ?entry_capture_evaluation_errors: Array[Hash[Symbol, String]]?, ?return_value: untyped?, ?duration: Float?, ?exception: Exception?) -> void attr_reader probe: Probe @@ -41,7 +41,7 @@ module Datadog attr_reader caller_locations: Array[Thread::Backtrace::Location]? attr_reader serialized_entry_args: Hash[Symbol, untyped]? - attr_reader entry_capture_expressions: Hash[String, untyped]? + attr_reader entry_capture_expressions: Hash[String, any]? attr_reader entry_capture_evaluation_errors: Array[Hash[Symbol, String]]? attr_reader return_value: untyped attr_reader duration: Float? diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index 7f4d5885ed..8eedc0b0ff 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -7,15 +7,15 @@ module Datadog EVALUATE_AT_STRINGS: Hash[String, Symbol] - def self?.build_from_remote_config: (Hash[String, untyped] config, logger: DI::Logger) -> Probe + def self?.build_from_remote_config: (Hash[String, any] config, logger: DI::Logger) -> Probe def self?.build_template_segments: (Array[Hash[String, untyped]]? segments) -> Array[String | DI::EL::Expression]? - def self?.build_capture_expressions: (Array[Hash[String, untyped]]? raw) -> Array[CaptureExpression] + def self?.build_capture_expressions: (Array[Hash[String, any]]? raw) -> Array[CaptureExpression] def self?.dedup_capture_expressions: (Array[CaptureExpression] capture_expressions, String? probe_id, DI::Logger logger) -> Array[CaptureExpression] - def self?.build_capture_limits: (Hash[String, untyped]? raw) -> CaptureLimits? + def self?.build_capture_limits: (Hash[String, any]? raw) -> CaptureLimits? def self?.parse_evaluate_at: (String? raw, String? probe_id, DI::Logger logger) -> Symbol end diff --git a/sig/datadog/di/serializer.rbs b/sig/datadog/di/serializer.rbs index 2a69335817..0ed2bba723 100644 --- a/sig/datadog/di/serializer.rbs +++ b/sig/datadog/di/serializer.rbs @@ -22,9 +22,9 @@ module Datadog attr_reader telemetry: Core::Telemetry::Component? def combine_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped target_self) -> Hash[Symbol, untyped] - def serialize_args: (Array[untyped] args, Hash[Symbol, untyped] kwargs, untyped instance_vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, untyped] - def serialize_vars: (Hash[Symbol, untyped] vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, untyped] - def serialize_value: (untyped value, ?name: (Symbol | String)?, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?, ?type: Class?) -> Hash[Symbol, untyped] + def serialize_args: (Array[any] args, Hash[Symbol, any] kwargs, any instance_vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, any] + def serialize_vars: (Hash[Symbol, any] vars, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?) -> Hash[Symbol, any] + def serialize_value: (any value, ?name: (Symbol | String)?, ?depth: Integer, ?attribute_count: Integer?, ?length: Integer?, ?collection_size: Integer?, ?type: Class?) -> Hash[Symbol, any] def serialize_value_for_message: (untyped value, ?::Integer depth) -> String? def self.register: (?condition: Proc?) {