From 3d5055d4829023a78abc2ee5f2bf27545699e0cb Mon Sep 17 00:00:00 2001 From: Oleg Pudeyev Date: Thu, 17 Oct 2024 12:29:21 -0400 Subject: [PATCH] DEBUG-2334 dynamic instrumentation probe notification builder This component creates status and snapshot payloads for probes. --- lib/datadog/di/probe_notification_builder.rb | 215 ++++++++++++++++++ .../di/probe_notification_builder_spec.rb | 4 + 2 files changed, 219 insertions(+) create mode 100644 lib/datadog/di/probe_notification_builder.rb create mode 100644 spec/datadog/di/probe_notification_builder_spec.rb diff --git a/lib/datadog/di/probe_notification_builder.rb b/lib/datadog/di/probe_notification_builder.rb new file mode 100644 index 00000000000..93e1650ac86 --- /dev/null +++ b/lib/datadog/di/probe_notification_builder.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +module Datadog + module DI + # Builds probe status notification and snapshot payloads. + # + # @api private + class ProbeNotificationBuilder + + def initialize(serializer) + @serializer = serializer + end + + attr_reader :serializer + + def build_received(probe) + build_status(probe, + message: "Probe #{probe.id} has been received correctly", + status: 'RECEIVED', + ) + end + + def build_installed(probe) + build_status(probe, + message: "Probe #{probe.id} has been instrumented correctly", + status: 'INSTALLED', + ) + end + + def build_emitting(probe) + build_status(probe, + message: "Probe #{probe.id} is emitting", + status: 'EMITTING', + ) + end + + def build_executed(probe, + trace_point: nil, rv: nil, duration: nil, callers: nil, + args: nil, kwargs: nil, serialized_entry_args: nil + ) + snapshot = if probe.line? && probe.capture_snapshot? + if trace_point.nil? + raise "Cannot create snapshot because there is no trace point" + end + get_local_variables(trace_point) + end + if callers + callers = callers[0..9] + end + build_snapshot(probe, rv: rv, snapshot: snapshot, + duration: duration, callers: callers, args: args, kwargs: kwargs, + serialized_entry_args: serialized_entry_args) + end + + def build_snapshot(probe, rv: nil, snapshot: nil, + duration: nil, callers: nil, args: nil, kwargs: nil, + serialized_entry_args: nil + ) + # TODO also verify that non-capturing probe does not pass + # snapshot or vars/args into this method + captures = if probe.capture_snapshot? + if probe.method? + { + entry: { + arguments: if serialized_entry_args + serialized_entry_args + else + (args || kwargs) && serializer.serialize_args(args, kwargs) + end, + throwable: nil, + }, + return: { + arguments: { + '@return': serializer.serialize_value(rv), + }, + throwable: nil, + }, + } + elsif probe.line? + { + lines: snapshot && { + probe.line_no => {locals: serializer.serialize_vars(snapshot)}, + }, + } + end + end + + location = if probe.line? + actual_file = if probe.file + # Normally callers should always be filled for a line probe + # but in the test suite we don't always provide all arguments. + callers&.detect do |caller| + File.basename(caller.sub(/:.*/, '')) == File.basename(probe.file) + end&.sub(/:.*/, '') || probe.file + end + { + file: actual_file, + lines: [probe.line_no], + } + elsif probe.method? + { + method: probe.method_name, + type: probe.type_name, + } + end + + stack = if callers + format_callers(callers) + end + + timestamp = timestamp_now + payload = { + service: Datadog.configuration.service, + 'debugger.snapshot': { + id: SecureRandom.uuid, + timestamp: timestamp, + evaluationErrors: [], + probe: { + id: probe.id, + version: 0, + location: location, + }, + language: 'ruby', + #language: 'python', + # TODO add test coverage for callers being nil + stack: stack, + captures: captures, + }, + # In python tracer duration is under debugger.snapshot, + # but UI appears to expect it here at top level. + duration: duration ? (duration * 10**9).to_i : nil, + host: nil, + logger: { + name: probe.file, + method: probe.method_name || 'no_method', + thread_name: Thread.current.name, + thread_id: Thread.current.native_thread_id, + version: 2, + }, + 'dd.trace_id': 136035165280417366521542318182735500431, + 'dd.span_id': 17576285113343575026, + ddsource: 'dd_debugger', + message: probe.template && evaluate_template(probe.template, + duration: duration ? duration * 1000 : nil), + timestamp: timestamp, + } + + payload + end + + def build_status(probe, message:, status:) + component = DI.component + # Component can be nil in unit tests. + return unless component + + payload = { + service: Datadog.configuration.service, + timestamp: timestamp_now, + message: message, + ddsource: 'dd_debugger', + debugger: { + diagnostics: { + probeId: probe.id, + probeVersion: 0, + runtimeId: Core::Environment::Identity.id, + parentId: nil, + status: status, + }, + }, + } + + payload + end + + def format_callers(callers) + callers.map do |caller| + if caller =~ /\A(.+):(\d+):in `([^']+)'\z/ + { + fileName: $1, function: $3, lineNumber: Integer($2), + } + else + { + fileName: 'unknown', function: 'unknown', lineNumber: 0, + } + end + end + end + + def evaluate_template(template, **vars) + message = template.dup + vars.each do |key, value| + message.gsub!("{@#{key}}", value.to_s) + end + message + end + + def timestamp_now + (Time.now.to_f * 1000).to_i + end + + def get_local_variables(trace_point) + # binding appears to be constructed on access, therefore + # 1) we should attempt to cache it and + # 2) we should not call +binding+ until we actually need variable values. + binding = trace_point.binding + binding.local_variables.inject({}) do |map, name| + value = binding.local_variable_get(name) + map[name] = value + map + 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 new file mode 100644 index 00000000000..99075215df6 --- /dev/null +++ b/spec/datadog/di/probe_notification_builder_spec.rb @@ -0,0 +1,4 @@ +require 'datadog/di/probe_notification_builder' + +RSpec.describe Datadog::DI::ProbeNotificationBuilder do +end