diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md index ac54404672..84e490760b 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. | `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/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index dc2212c43f..cd18959ae8 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -50,6 +50,7 @@ module Configuration "DD_DBM_PROPAGATION_MODE", "DD_DISABLE_DATADOG_RAILS", "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/boot.rb b/lib/datadog/di/boot.rb index fba3309571..00ac2f1154 100644 --- a/lib/datadog/di/boot.rb +++ b/lib/datadog/di/boot.rb @@ -6,6 +6,9 @@ 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' 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..28f8205e38 --- /dev/null +++ b/lib/datadog/di/capture_expression.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "capture_limits" + +module Datadog + module DI + 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 + 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..c9036730c5 --- /dev/null +++ b/lib/datadog/di/capture_expression_evaluator.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "capture_expression" +require_relative "capture_limits" +require_relative "fatal_exceptions" + +module Datadog + module DI + class CaptureExpressionEvaluator + TELEMETRY_NAMESPACE = "dynamic_instrumentation" + + def initialize(settings:, serializer:, logger:, telemetry: nil) + @settings = settings + @serializer = serializer + @logger = logger + @telemetry = telemetry + end + + attr_reader :settings + + attr_reader :serializer + + attr_reader :logger + + attr_reader :telemetry + + 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 + + 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(TELEMETRY_NAMESPACE, "capture_expressions_skipped_by_timeout", 1) + next + end + + begin + value = capture_expression.expr.evaluate(context) + limits = CaptureLimits.resolve( + expr_limits: capture_expression.limits, + probe: probe, + settings: settings, + ) + 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 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}" + 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/capture_limits.rb b/lib/datadog/di/capture_limits.rb new file mode 100644 index 0000000000..0558446850 --- /dev/null +++ b/lib/datadog/di/capture_limits.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Datadog + module DI + 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 + + 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/component.rb b/lib/datadog/di/component.rb index c8f5bf466b..1d6227de8c 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, logger, telemetry: telemetry) @probe_notifier_worker = ProbeNotifierWorker.new( settings, logger, agent_settings: agent_settings, @@ -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/configuration.rb b/lib/datadog/di/configuration.rb index 54a653b097..e26c5fa609 100644 --- a/lib/datadog/di/configuration.rb +++ b/lib/datadog/di/configuration.rb @@ -134,6 +134,12 @@ def self.add_settings!(base) o.default 20 end + option :max_time_to_serialize_ms do |o| + o.type :int + o.default 200 + o.env 'DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE' + 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/context.rb b/lib/datadog/di/context.rb index 61875f63ac..9675300363 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,8 @@ def initialize(probe:, settings:, serializer:, locals: nil, @path = path @caller_locations = caller_locations @serialized_entry_args = serialized_entry_args + @entry_capture_expressions = entry_capture_expressions + @entry_capture_evaluation_errors = entry_capture_evaluation_errors @return_value = return_value @duration = duration @exception = exception @@ -44,6 +48,10 @@ def initialize(probe:, settings:, serializer:, locals: nil, attr_reader :serialized_entry_args + attr_reader :entry_capture_expressions + + attr_reader :entry_capture_evaluation_errors + # Return value for the method, for a method probe attr_reader :return_value @@ -55,9 +63,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/el/compiler.rb b/lib/datadog/di/el/compiler.rb index 9e0554483c..5be7faee4b 100644 --- a/lib/datadog/di/el/compiler.rb +++ b/lib/datadog/di/el/compiler.rb @@ -78,7 +78,7 @@ 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)" + "(context.duration && context.duration * 1000)" when '@exception' "context.exception" else diff --git a/lib/datadog/di/instrumenter.rb b/lib/datadog/di/instrumenter.rb index dc7a357b4a..d57c7aac84 100644 --- a/lib/datadog/di/instrumenter.rb +++ b/lib/datadog/di/instrumenter.rb @@ -3,6 +3,7 @@ require_relative '../core/utils/time' require_relative '../ruby_version' require_relative 'fatal_exceptions' +require_relative 'capture_expression_evaluator' # rubocop:disable Lint/AssignmentInCondition # rubocop:disable Style/AndOr @@ -84,6 +85,12 @@ def initialize(settings, serializer, logger, code_tracker: nil, telemetry: nil) attr_reader :telemetry attr_reader :code_tracker + 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 @@ -520,8 +527,27 @@ def run_method_probe(args, kwargs, target_block, target_self, # they need to be serialized prior to method invocation. serialized_entry_args = if probe.capture_snapshot? serializer.serialize_args(args, kwargs, target_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 + + entry_capture_expressions = nil + entry_capture_evaluation_errors = nil + if probe.capture_entry_expressions? + begin + entry_context = Context.new( + probe: probe, settings: settings, serializer: serializer, + target_self: target_self, + locals: serializer.combine_args(args, kwargs, target_self), + ) + entry_capture_expressions, entry_capture_evaluation_errors = + capture_expression_evaluator.evaluate(probe, entry_context) + rescue Exception => exc # standard:disable Lint/RescueException + Datadog::DI.reraise_if_fatal(exc) + raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions + + logger.debug { "di: error evaluating entry-time capture expressions: #{exc.class}: #{exc.message}" } + 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 @@ -584,9 +610,14 @@ def run_method_probe(args, kwargs, target_block, target_self, caller_locs = method_frame + (caller_locations(2) || []) # TODO capture arguments at exit - context = Context.new(locals: nil, target_self: target_self, + 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, 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 e065cc8631..9a358e0f9b 100644 --- a/lib/datadog/di/probe.rb +++ b/lib/datadog/di/probe.rb @@ -34,11 +34,17 @@ module DI class Probe KNOWN_TYPES = %i[log].freeze + 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, 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: [], + evaluate_at: nil, + 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 +87,17 @@ 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 || [] + 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 - @rate_limit = rate_limit || (@capture_snapshot ? 1 : 5000) + @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 +134,14 @@ def initialize(id:, type:, # the global default will be used. attr_reader :max_capture_attribute_count + attr_reader :max_capture_collection_size + + attr_reader :max_capture_string_length + + attr_reader :capture_expressions + + attr_reader :evaluate_at + # Rate limit in effect, in invocations per second. Always present. attr_reader :rate_limit @@ -140,6 +162,29 @@ def capture_snapshot? @capture_snapshot end + 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) + CaptureLimits.resolve(expr_limits: nil, probe: self, settings: settings) + 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..ecfb3a0e09 100644 --- a/lib/datadog/di/probe_builder.rb +++ b/lib/datadog/di/probe_builder.rb @@ -3,6 +3,8 @@ # rubocop:disable Lint/AssignmentInCondition require_relative "probe" +require_relative "capture_expression" +require_relative "capture_limits" require_relative 'el' module Datadog @@ -27,7 +29,15 @@ module ProbeBuilder module_function - def build_from_remote_config(config) + CAPTURE_EXPRESSION_NAME_PATTERN = /\A[a-zA-Z0-9_?]+\z/ + + EVALUATE_AT_STRINGS = { + "ENTRY" => :entry, + "EXIT" => :exit, + "DEFAULT" => :exit, + }.freeze + + 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}" @@ -38,6 +48,13 @@ 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']) + capture_expressions = dedup_capture_expressions(capture_expressions, config["id"], logger) + if !!config["captureSnapshot"] && !capture_expressions.empty? + 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,10 @@ 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, + evaluate_at: parse_evaluate_at(config["evaluateAt"], config["id"], logger), rate_limit: config["sampling"]&.[]("snapshotsPerSecond"), condition: cond, ) @@ -61,6 +82,67 @@ 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? + 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}" + 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 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 + + 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 parse_evaluate_at(raw, probe_id, logger) + return :exit if raw.nil? + EVALUATE_AT_STRINGS[raw] || begin + 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 e576940d14..f39170bca9 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 @@ -10,14 +11,26 @@ module DI # # @api private class ProbeNotificationBuilder - def initialize(settings, serializer) + def initialize(settings, serializer, logger, telemetry: nil) @settings = settings @serializer = serializer + @logger = logger + @telemetry = telemetry + @capture_expression_evaluator = CaptureExpressionEvaluator.new( + 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 + def build_received(probe) build_status(probe, message: "Probe #{probe.id} has been received correctly", @@ -67,13 +80,13 @@ def build_snapshot(context) # 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? + 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: { @@ -89,11 +102,38 @@ 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)}, }, }, } end + elsif probe.capture_expressions? + if probe.method? + 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}, + }, + } + end end message = nil @@ -101,6 +141,7 @@ def build_snapshot(context) if segments = probe.template_segments message, evaluation_errors = evaluate_template(segments, context) end + evaluation_errors.concat(capture_expression_evaluation_errors) build_snapshot_base(context, evaluation_errors: evaluation_errors, message: message, captures: captures) diff --git a/lib/datadog/di/serializer.rb b/lib/datadog/di/serializer.rb index a46251d039..32d6e05a82 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 @@ -163,6 +169,8 @@ def serialize_vars(vars, 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 @@ -235,7 +243,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 +272,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 +281,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 +289,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 +299,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 +339,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/core/configuration/settings.rbs b/sig/datadog/core/configuration/settings.rbs index 10ec922d3e..c4585bc878 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 diff --git a/sig/datadog/di/capture_expression.rbs b/sig/datadog/di/capture_expression.rbs new file mode 100644 index 0000000000..486a2124bc --- /dev/null +++ b/sig/datadog/di/capture_expression.rbs @@ -0,0 +1,15 @@ +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 + 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..91aee1d8f4 --- /dev/null +++ b/sig/datadog/di/capture_expression_evaluator.rbs @@ -0,0 +1,21 @@ +module Datadog + module DI + class CaptureExpressionEvaluator + TELEMETRY_NAMESPACE: String + + @settings: Datadog::Core::Configuration::Settings + @serializer: Datadog::DI::Serializer + @logger: DI::Logger + @telemetry: Core::Telemetry::Component? + + 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? + + def evaluate: (Probe probe, Context context) -> [Hash[String, any], Array[Hash[Symbol, String]]] + 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..83dca31c2d --- /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: Datadog::Core::Configuration::Settings) -> Hash[Symbol, Integer] + end + end +end diff --git a/sig/datadog/di/context.rbs b/sig/datadog/di/context.rbs index 39b3021d3a..d0e29e7762 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, any]? + @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, any]?, ?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, any]? + 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 efad6bc7f1..e403f900b2 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 a8aa33167e..578cf6565f 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,9 +31,15 @@ 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?, ?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], + ?evaluate_at: Symbol?, + ?rate_limit: Integer?) -> void attr_reader condition: DI::EL::Expression? @@ -52,6 +59,14 @@ 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 evaluate_at: Symbol + attr_reader template: String? attr_reader template_segments: Array[String | DI::EL::Expression]? attr_reader rate_limiter: Datadog::Core::RateLimiter @@ -59,6 +74,11 @@ module Datadog attr_reader condition_evaluation_failed_rate_limiter: Datadog::Core::RateLimiter? 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 def line_no!: () -> Integer diff --git a/sig/datadog/di/probe_builder.rbs b/sig/datadog/di/probe_builder.rbs index 4bbfdb7ac1..8eedc0b0ff 100644 --- a/sig/datadog/di/probe_builder.rbs +++ b/sig/datadog/di/probe_builder.rbs @@ -3,9 +3,21 @@ module Datadog module ProbeBuilder PROBE_TYPES: { "LOG_PROBE" => :log } - def self?.build_from_remote_config: (Hash[String, untyped] config) -> Probe + CAPTURE_EXPRESSION_NAME_PATTERN: Regexp + + EVALUATE_AT_STRINGS: Hash[String, Symbol] + + 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, 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, any]? raw) -> CaptureLimits? + + def self?.parse_evaluate_at: (String? raw, String? probe_id, DI::Logger logger) -> Symbol end end end diff --git a/sig/datadog/di/probe_notification_builder.rbs b/sig/datadog/di/probe_notification_builder.rbs index 006d5b013b..d0aa5d39bb 100644 --- a/sig/datadog/di/probe_notification_builder.rbs +++ b/sig/datadog/di/probe_notification_builder.rbs @@ -7,11 +7,17 @@ module Datadog @settings: Datadog::Core::Configuration::Settings @serializer: Serializer + @logger: DI::Logger + @telemetry: Core::Telemetry::Component? + @capture_expression_evaluator: CaptureExpressionEvaluator - def initialize: (Datadog::Core::Configuration::Settings settings, Serializer serializer) -> 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 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..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?) -> 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_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?) { 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..0e08a43d67 --- /dev/null +++ b/spec/datadog/di/capture_expression_evaluator_spec.rb @@ -0,0 +1,274 @@ +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(:max_time_to_serialize_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(: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, logger: logger, telemetry: telemetry) + 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 + 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 + + 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 "expression evaluation raises a non-StandardError" do + 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 + 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( + 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(:max_time_to_serialize_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 + + it "increments the timeout telemetry counter for each timed-out expression" do + expect(telemetry).to receive(:inc).with( + "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) + 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( + 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 + + 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..c52c2a686e --- /dev/null +++ b/spec/datadog/di/capture_expression_spec.rb @@ -0,0 +1,20 @@ +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 diff --git a/spec/datadog/di/capture_limits_spec.rb b/spec/datadog/di/capture_limits_spec.rb new file mode 100644 index 0000000000..37f94bc75a --- /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 + instance_double(Datadog::Core::Configuration::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/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/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/instrumenter_spec.rb b/spec/datadog/di/instrumenter_spec.rb index 25ff41a1db..3ea6ae5a1b 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 1917ea034c..5e958bb3d5 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 @@ -560,6 +564,332 @@ 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), logger: logger) + 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).not_to have_key(:entry) + 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), logger: logger) + 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 = +'hello world' + InstrumentationSpecTestClass.new.mutating_method(greeting) + expect(greeting).to eq('bye world') + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "greeting_at_entry" => {type: 'String', value: 'hello world'}, + ) + expect(captures).not_to have_key(:return) + 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), logger: logger) + end + + 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) + expect(captures.fetch(:entry).fetch(:captureExpressions)).to eq( + "duration_at_entry" => {type: 'NilClass', isNull: true}, + ) + expect(captures).not_to have_key(:return) + 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), logger: logger) + 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(+'hello world') + component.probe_notifier_worker.flush + + captures = payload.fetch(:debugger).fetch(:snapshot).fetch(:captures) + 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), logger: logger) + 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), logger: logger) + 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 in the return 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(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).not_to have_key(:entry) + 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), logger: logger) + end + + 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).not_to have_key(:entry) + + evaluation_errors = snapshot.fetch(:evaluationErrors) + 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, + 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) + expect(captures.fetch(:entry).fetch(:arguments).fetch(:arg1)).to include( + type: 'String', value: 'hell', truncated: true, + ) + expect(captures.fetch(:return).fetch(:arguments).fetch(:@return)).to include( + type: 'String', value: 'bye ', truncated: true, + ) + 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 + 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) + 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) + 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) + 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, @@ -603,7 +933,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 @@ -891,6 +1221,42 @@ 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), logger: logger) + end + + 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, @@ -1137,6 +1503,35 @@ def run_test include_examples 'assembles expected notification payload' end + + context 'with probe-level maxCollectionSize override on line-probe locals' do + 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, + ) + 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 diff --git a/spec/datadog/di/integration/probe_notification_builder_spec.rb b/spec/datadog/di/integration/probe_notification_builder_spec.rb index 29b79996d0..6e3b44272a 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) @@ -148,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 ee95dc99b7..c60a9b6927 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 @@ -175,5 +178,245 @@ 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 "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( + "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(logger).to receive(:debug) do |&block| + expect(block.call).to match(/captureSnapshot=true wins over captureExpressions/) + end + probe + 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"}) + 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 + + 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\"" do + let(:rc_probe_spec) { base_spec.merge("evaluateAt" => "DEFAULT") } + + it "maps to :exit" 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(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 fa6288d077..ff49c95447 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,13 +36,17 @@ 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 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) @@ -703,6 +709,196 @@ 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 in the return block and omits the entry block' do + payload = builder.build_snapshot(context) + captures = payload[:debugger][:snapshot][:captures] + expect(captures).not_to have_key(:entry) + expect(captures[:return][:captureExpressions].keys).to eq(["x"]) + expect(captures[:return][: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 '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 + 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 + 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) diff --git a/spec/datadog/di/probe_spec.rb b/spec/datadog/di/probe_spec.rb index 3283c21cb0..64a00055a5 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 @@ -198,6 +200,185 @@ end end + describe "capture expressions" do + let(:capture_expression) do + Datadog::DI::CaptureExpression.new(name: "x", expr: instance_double(Datadog::DI::EL::Expression)) + 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 "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 + 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 "#method_name!" do context "method name set" do let(:probe) { described_class.new(id: "x", type: :log, type_name: "Foo", method_name: "bar") } @@ -235,4 +416,65 @@ end end end + + describe "#snapshot_serializer_limits" do + let(:settings) do + instance_double(Datadog::Core::Configuration::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 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: diff --git a/supported-configurations.json b/supported-configurations.json index e03ad8ae66..b6febcdf04 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -294,6 +294,13 @@ "default": "false" } ], + "DD_DYNAMIC_INSTRUMENTATION_MAX_TIME_TO_SERIALIZE": [ + { + "version": "A", + "type": "int", + "default": "200" + } + ], "DD_DYNAMIC_INSTRUMENTATION_PROBE_FILE": [ { "version": "A",