Skip to content

Commit

Permalink
DEBUG-2334 dynamic instrumentation probe notification builder
Browse files Browse the repository at this point in the history
This component creates status and snapshot payloads for probes.
  • Loading branch information
p committed Oct 17, 2024
1 parent 60febf8 commit b33be73
Show file tree
Hide file tree
Showing 5 changed files with 462 additions and 1 deletion.
216 changes: 216 additions & 0 deletions lib/datadog/di/probe_notification_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# frozen_string_literal: true

module Datadog
module DI
# Builds probe status notification and snapshot payloads.
#
# @api private
class ProbeNotificationBuilder
def initialize(settings, serializer)
@settings = settings
@serializer = serializer
end

attr_reader :settings
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: {
# standard:disable all
arguments: if serialized_entry_args
serialized_entry_args
else
(args || kwargs) && serializer.serialize_args(args, kwargs)
end,
throwable: nil,
# standard:enable all
},
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
{
service: settings.service,
"debugger.snapshot": {
id: SecureRandom.uuid,
timestamp: timestamp,
evaluationErrors: [],
probe: {
id: probe.id,
version: 0,
location: location,
},
language: 'ruby',
# 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_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,
}
end

def build_status(probe, message:, status:)
{
service: settings.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,
},
},
}
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

# steep hack - should never happen
return {} unless binding

binding.local_variables.each_with_object({}) do |name, map|
value = binding.local_variable_get(name)
map[name] = value
end
end

def thread_id
thread = Thread.current
if thread.respond_to?(:native_thread_id)
# Ruby 3.1+
thread.native_thread_id
else
thread.object_id
end
end
end
end
end
36 changes: 36 additions & 0 deletions sig/datadog/di/probe_notification_builder.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module Datadog
module DI
class ProbeNotificationBuilder
@serializer: Serializer

def initialize: (untyped settings, Serializer serializer) -> void

attr_reader settings: untyped
attr_reader serializer: Serializer

def build_received: (Probe probe) -> untyped

def build_installed: (Probe probe) -> untyped

def build_emitting: (Probe probe) -> untyped

def build_executed: (Probe probe, ?trace_point: untyped?, ?rv: untyped?, ?duration: untyped?, ?callers: untyped?, ?args: untyped?, ?kwargs: untyped?, ?serialized_entry_args: untyped?) -> untyped

def build_snapshot: (Probe probe, ?rv: untyped?, ?snapshot: untyped?, ?duration: untyped?, ?callers: untyped?, ?args: untyped?, ?kwargs: untyped?, ?serialized_entry_args: untyped?) -> { service: untyped, :"debugger.snapshot" => { id: untyped, timestamp: untyped, evaluationErrors: ::Array[untyped], probe: { id: untyped, version: 0, location: untyped }, language: "ruby", stack: untyped, captures: untyped }, duration: untyped, host: nil, logger: { name: untyped, method: untyped, thread_name: untyped, thread_id: untyped, version: 2 }, :"dd.trace_id" => 136035165280417366521542318182735500431, :"dd.span_id" => 17576285113343575026, ddsource: "dd_debugger", message: untyped, timestamp: untyped }

def build_status: (Probe probe, message: untyped, status: untyped) -> (nil | { service: untyped, timestamp: untyped, message: untyped, ddsource: "dd_debugger", debugger: { diagnostics: { probeId: untyped, probeVersion: 0, runtimeId: untyped, parentId: nil, status: untyped } } })

def format_callers: (untyped callers) -> untyped

def evaluate_template: (untyped template, **untyped vars) -> untyped

def timestamp_now: () -> untyped

def get_local_variables: (TracePoint trace_point) -> untyped

private

def thread_id: -> Integer
end
end
end
2 changes: 1 addition & 1 deletion sig/datadog/di/serializer.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ module Datadog

def serialize_args: (untyped args, untyped kwargs) -> untyped
def serialize_vars: (untyped vars) -> untyped
def serialize_value: (untyped value, ?name: String, ?depth: Integer) -> untyped

private
def serialize_value: (untyped value, ?name: String, ?depth: untyped) -> ({ type: untyped, notCapturedReason: "redactedType" } | { type: untyped, notCapturedReason: "redactedIdent" } | untyped)
def class_name: (untyped cls) -> untyped
end
end
Expand Down
108 changes: 108 additions & 0 deletions spec/datadog/di/integration/probe_notification_builder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
require 'datadog/di/serializer'
require 'datadog/di/probe'
require 'datadog/di/probe_notification_builder'

RSpec.describe Datadog::DI::ProbeNotificationBuilder do
describe 'log probe' do
let(:redactor) { Datadog::DI::Redactor.new(settings) }
let(:serializer) { Datadog::DI::Serializer.new(settings, redactor) }

let(:builder) { described_class.new(settings, serializer) }

let(:instrumenter) do
Datadog::DI::Instrumenter.new(settings)
end

let(:settings) do
double('settings').tap do |settings|
allow(settings).to receive(:dynamic_instrumentation).and_return(di_settings)
allow(settings).to receive(:service).and_return('fake service')
end
end

let(:di_settings) do
double('di settings').tap do |settings|
allow(settings).to receive(:enabled).and_return(true)
allow(settings).to receive(:untargeted_trace_points).and_return(false)
allow(settings).to receive(:max_capture_depth).and_return(2)
allow(settings).to receive(:max_capture_string_length).and_return(20)
allow(settings).to receive(:max_capture_collection_size).and_return(20)
allow(settings).to receive(:redacted_type_names).and_return([])
allow(settings).to receive(:redacted_identifiers).and_return([])
end
end

context 'line probe' do
let(:probe) do
Datadog::DI::Probe.new(id: '123', type: :log, file: 'X', line_no: 1, capture_snapshot: true)
end

context 'with snapshot' do
let(:vars) do
{hello: 42, hash: {hello: 42, password: 'redacted'}, array: [true]}
end

let(:captures) do
{lines: {1 => {
locals: {
hello: {type: 'Integer', value: '42'},
hash: {type: 'Hash', entries: [
[{type: 'Symbol', value: 'hello'}, {type: 'Integer', value: '42'}],
[{type: 'Symbol', value: 'password'}, {type: 'String', notCapturedReason: 'redactedIdent'}],
]},
array: {type: 'Array', elements: [
{type: 'TrueClass', value: 'true'},
]},
},
}}}
end

it 'builds expected payload' do
payload = builder.build_snapshot(probe, snapshot: vars)
expect(payload).to be_a(Hash)
expect(payload.fetch(:"debugger.snapshot").fetch(:captures)).to eq(captures)
end
end
end

context 'method probe' do
let(:probe) do
Datadog::DI::Probe.new(id: '123', type: :log, type_name: 'X', method_name: 'y', capture_snapshot: true)
end

context 'with snapshot' do
let(:args) do
[1, 'hello']
end

let(:kwargs) do
{foo: 42}
end

let(:expected_captures) do
{entry: {
arguments: {
arg1: {type: 'Integer', value: '1'},
arg2: {type: 'String', value: 'hello'},
foo: {type: 'Integer', value: '42'},
}, throwable: nil,
}, return: {
arguments: {
:@return => {
type: 'NilClass',
isNull: true,
},
}, throwable: nil,
}}
end

it 'builds expected payload' do
payload = builder.build_snapshot(probe, args: args, kwargs: kwargs)
expect(payload).to be_a(Hash)
captures = payload.fetch(:"debugger.snapshot").fetch(:captures)
expect(captures).to eq(expected_captures)
end
end
end
end
end
Loading

0 comments on commit b33be73

Please sign in to comment.