Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6366dc5
create ddsketch.h like the other components
p Dec 4, 2025
6d92220
runtime iseqs
p Dec 4, 2025
39b2d27
exception_message
p Dec 5, 2025
94aa14a
remove wip require
p Dec 8, 2025
fa6a15f
rakefile bits
p Dec 8, 2025
a66b254
steep standard
p Dec 8, 2025
fecec13
Remove ddsketch.h and di.h, inline forward declarations in init.c
p-ddsign Mar 23, 2026
0676421
Consolidate C helper files into di.c and datadog_ruby_common.h
p-ddsign Mar 23, 2026
5a2463a
Simplify all_iseqs and move consumer-side docs to lib/datadog/di.rb
p-ddsign Mar 23, 2026
897bf2c
Fix trailing whitespace and add @return tag to file_iseqs
p-ddsign Mar 23, 2026
4ef85c9
Sync datadog_ruby_common.h between profiling and libdatadog_api exten…
p-ddsign Mar 23, 2026
e2d57cd
Populate throwable in method probe snapshots using exception_message
p-ddsign Mar 23, 2026
b3ef61d
Rename 'raw' variable to 'msg' to fix semgrep false positive
p-ddsign Mar 23, 2026
b7bbee2
Add error boundary around probe_executed_callback in method probes
p-ddsign Mar 24, 2026
e0230ed
Handle nil exception constructor argument in serialize_throwable
p-ddsign Mar 24, 2026
7ec9f32
Handle nil exception message and document exception reporting caveats
p-ddsign Mar 24, 2026
390db62
Add integration test for method probe exception throwable capture
p-ddsign Mar 24, 2026
a7a8a51
Address review findings: YARD tags, logger verification, freeze constant
p-ddsign Mar 24, 2026
1e70fd3
Require C extension for DI; remove fallback code paths
p-ddsign Mar 24, 2026
0eb8f18
Move all DI specs to di_with_ext task (requires C extension)
p-ddsign Mar 24, 2026
a2e53d7
Skip DI component test in spec:main when C extension unavailable
p-ddsign Mar 24, 2026
5a2b108
Add test for DI disabled when C extension is absent on MRI
p-ddsign Mar 24, 2026
4bbff23
Return redacted placeholder for non-string exception constructor args
p-ddsign Mar 24, 2026
6eabbe0
Improve "Application Data Sent to Datadog" docs section
p-ddsign Mar 24, 2026
3e7d756
Merge branch 'master' into di-c-ext
p-datadog Mar 24, 2026
0d1c652
Add stacktrace to throwable in method probe snapshots
p-ddsign Mar 24, 2026
933923f
Return empty array instead of nil for throwable stacktrace
p-ddsign Mar 24, 2026
12ea888
Add di_with_ext to Matrixfile; fix throwable integration test
p-ddsign Mar 25, 2026
98b25fe
Revert "Add di_with_ext to Matrixfile; fix throwable integration test"
p-ddsign Mar 25, 2026
e8ae9f7
Add di_with_ext to Matrixfile
p-ddsign Mar 25, 2026
e663a39
Fix throwable integration test to include stacktrace
p-ddsign Mar 25, 2026
b162e3d
Restore original error message assertion after cherry-pick
p-ddsign Mar 25, 2026
dde515f
Fix di_with_ext task name in Matrixfile and spec:all
p-ddsign Mar 25, 2026
15dc3eb
Exclude DI contrib specs from di_with_ext task
p-ddsign Mar 25, 2026
b6577c2
Fix StandardRB style: use double-quoted symbol
p-ddsign Mar 25, 2026
6d63f39
Extract backtrace frame regex into named constant
p-ddsign Mar 27, 2026
3539fa7
Fix Steep type check: add missing BACKTRACE_FRAME_PATTERN RBS declara…
p-ddsign Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Matrixfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
'core_with_libdatadog_api' => {
'' => '✅ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 4.0 / ❌ jruby',
},
# DI tests that require the libdatadog_api C extension (all_iseqs, exception_message, iseq_type).
# script_compiled trace point requires Ruby 2.6+. C extensions don't compile on JRuby.
'di:di_with_ext' => {
'' => '❌ 2.5 / ✅ 2.6 / ✅ 2.7 / ✅ 3.0 / ✅ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 4.0 / ❌ jruby',
},
'core_with_rails' => {
# Run with Rails integration
'rails8' => '❌ 2.5 / ❌ 2.6 / ❌ 2.7 / ❌ 3.0 / ❌ 3.1 / ✅ 3.2 / ✅ 3.3 / ✅ 3.4 / ✅ 3.5 / ❌ jruby',
Expand Down
19 changes: 18 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ CORE_WITH_LIBDATADOG_API = [
'spec/datadog/core/datadog_ruby_common_spec.rb',
].freeze

DI_WITH_EXT = %w[
spec/datadog/di/*_spec.rb
spec/datadog/di/**/*_spec.rb
].freeze

# Data Streams Monitoring (DSM) requires libdatadog_api for DDSketch
# Add new instrumentation libraries here as they gain DSM support
DSM_ENABLED_LIBRARIES = [
Expand Down Expand Up @@ -89,14 +94,16 @@ namespace :spec do
:graphql, :graphql_unified_trace_patcher, :graphql_trace_patcher, :graphql_tracing_patcher,
:rails, :railsredis, :railsredis_activesupport, :railsactivejob,
:elasticsearch, :http, :redis, :sidekiq, :sinatra, :hanami, :hanami_autoinstrument,
:profiling, :core_with_libdatadog_api, :error_tracking, :open_feature, :core_with_rails, :environment, :ai_guard]
:profiling, :core_with_libdatadog_api, :"di:di_with_ext", :error_tracking, :open_feature, :core_with_rails, :environment, :ai_guard]

desc '' # "Explicitly hiding from `rake -T`"
RSpec::Core::RakeTask.new(:main) do |t, args|
t.pattern = 'spec/**/*_spec.rb'
t.exclude_pattern = 'spec/**/{appsec/integration,contrib,benchmark,redis,auto_instrument,opentelemetry,open_feature,profiling,error_tracking,rubocop,ai_guard}/**/*_spec.rb,' \
' spec/**/{auto_instrument,opentelemetry,process,ai_guard}_spec.rb,' \
' spec/datadog/core/environment/execution_spec.rb,' \
' spec/datadog/di/*_spec.rb,' \
' spec/datadog/di/**/*_spec.rb,' \
' spec/datadog/gem_packaging_spec.rb,' \
+ CORE_WITH_LIBDATADOG_API.join(', ')
t.rspec_opts = args.to_a.join(' ')
Expand Down Expand Up @@ -442,6 +449,16 @@ namespace :spec do
t.rspec_opts = args.to_a.join(' ')
end
end

# rubocop:disable Style/MultilineBlockChain
RSpec::Core::RakeTask.new(:di_with_ext) do |t, args|
t.pattern = DI_WITH_EXT.join(', ')
t.exclude_pattern = 'spec/datadog/di/contrib/**/*_spec.rb'
t.rspec_opts = args.to_a.join(' ')
end.tap do |t|
Rake::Task[t.name].enhance(["compile:libdatadog_api.#{RUBY_VERSION[/\d+.\d+/]}_#{RUBY_PLATFORM}"])
end
# rubocop:enable Style/MultilineBlockChain
end

namespace :profiling do
Expand Down
35 changes: 22 additions & 13 deletions docs/DynamicInstrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ practices for using Dynamic Instrumentation.
- Datadog Agent 7.49.0 or higher
- Ruby 2.6 or higher
- Only MRI (CRuby) is supported; JRuby and other Ruby implementations are not currently supported
- The `libdatadog_api` C extension must be compiled; DI will not
activate without it
- Rack-based applications only
- Includes Rails, Sinatra, and other Rack-compatible frameworks
- Non-Rack applications are not currently supported
Expand Down Expand Up @@ -288,19 +290,26 @@ serializer will be skipped and the next serializer will be tried. This
prevents custom serializers from breaking the entire serialization process.
The value will fall back to default serialization.

## Application Data Sent to Datadog

Dynamic instrumentation sends some of the application data to Datadog.
The following data is generally sent:

- Class names of objects
- Serialized object values, subject to redaction. There are built-in
redaction rules based on identifier names that are always active.
Additionally, it is possible to provide a list of class names whose
object values should always be redacted, and a list of additional
identifiers to be redacted.
- Exception class names and messages
- Exception stack traces
## What Data Is Captured

When a probe fires, Dynamic Instrumentation captures a snapshot of
application state and sends it to Datadog. The snapshot includes:

- **Variable values** — local variables, method arguments, and return
values, subject to the capture depth and collection size limits
described below. Values are automatically redacted when their
identifier names match built-in redaction rules. You can also
configure additional identifiers and class names to redact.
- **Object class names** — the class of each captured value.
- **Exception details** (method probes only) — the exception class name
and the message passed to the exception's constructor.
- The reported message is the value given to the constructor, not the
return value of the `message` method. If a custom exception class
overrides `message`, the reported value may differ.
- If the constructor argument is not a string (or is nil), the
exception type is still reported but the message will show as
redacted.
- **Stack traces** — the call stack at the point the probe fires.

## Rate Limiting and Performance

Expand Down
10 changes: 10 additions & 0 deletions ext/datadog_profiling_native_extension/datadog_ruby_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,13 @@ static inline VALUE get_error_details_and_drop(ddog_Error *error) {
// Returns the amount of characters written to string (which are necessarily
// bounded by capacity - 1 since the string will be null-terminated).
size_t read_ddogerr_string_and_drop(ddog_Error *error, char *string, size_t capacity);

#define IMEMO_MASK 0x0f

// Returns the imemo type of an imemo object.
// This mask is the same between Ruby 2.5 and 3.3-preview3. Furthermore, the intention of this method is to be used
// to call `rb_imemo_name` which correctly handles invalid numbers so even if the mask changes in the future, at most
// we'll get incorrect results (and never a VM crash)
static inline int ddtrace_imemo_type(VALUE imemo) {
return (RBASIC(imemo)->flags >> FL_USHIFT) & IMEMO_MASK;
}
10 changes: 10 additions & 0 deletions ext/libdatadog_api/datadog_ruby_common.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,13 @@ static inline VALUE get_error_details_and_drop(ddog_Error *error) {
// Returns the amount of characters written to string (which are necessarily
// bounded by capacity - 1 since the string will be null-terminated).
size_t read_ddogerr_string_and_drop(ddog_Error *error, char *string, size_t capacity);

#define IMEMO_MASK 0x0f

// Returns the imemo type of an imemo object.
// This mask is the same between Ruby 2.5 and 3.3-preview3. Furthermore, the intention of this method is to be used
// to call `rb_imemo_name` which correctly handles invalid numbers so even if the mask changes in the future, at most
// we'll get incorrect results (and never a VM crash)
static inline int ddtrace_imemo_type(VALUE imemo) {
return (RBASIC(imemo)->flags >> FL_USHIFT) & IMEMO_MASK;
}
79 changes: 79 additions & 0 deletions ext/libdatadog_api/di.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#include <stdbool.h>

#include "datadog_ruby_common.h"

// Prototypes for Ruby functions declared in internal Ruby headers.
VALUE rb_iseqw_new(const void *iseq);
int rb_objspace_internal_object_p(VALUE obj);
void rb_objspace_each_objects(
int (*callback)(void *start, void *end, size_t stride, void *data),
void *data);

#define IMEMO_TYPE_ISEQ 7

// The ID value of the string "mesg" which is used in Ruby source as
// id_mesg or idMesg, and is used to set and retrieve the exception message
// from standard library exception classes like NameError.
static ID id_mesg;

// Returns whether the argument is an IMEMO of type ISEQ.
static bool ddtrace_imemo_iseq_p(VALUE v) {
return rb_objspace_internal_object_p(v) && RB_TYPE_P(v, T_IMEMO) && ddtrace_imemo_type(v) == IMEMO_TYPE_ISEQ;
}

static int ddtrace_di_os_obj_of_i(void *vstart, void *vend, size_t stride, void *data)
{
VALUE *array = (VALUE *)data;

VALUE v = (VALUE)vstart;
for (; v != (VALUE)vend; v += stride) {
if (ddtrace_imemo_iseq_p(v)) {
VALUE iseq = rb_iseqw_new((void *) v);
rb_ary_push(*array, iseq);
}
}

return 0;
}

/*
Returns all RubyVM::InstructionSequence objects existing in the current process.

This uses the same approach as ruby/debug's iseq_collector.c:
https://github.com/ruby/debug/blob/master/ext/debug/iseq_collector.c
*/
static VALUE all_iseqs(DDTRACE_UNUSED VALUE _self) {
VALUE array = rb_ary_new();
rb_objspace_each_objects(ddtrace_di_os_obj_of_i, &array);
return array;
}

/*
* call-seq:
* DI.exception_message(exception) -> String | Object
*
* Returns the exception message associated with the exception via the
* exception's constructor.
*
* This method does not invoke Ruby code and as such will not call
* the +message+ method, if one is defined on the exception object.
*
* Normally, the exception message is a string, however there is no
* type enforcement done by Ruby for the messages and objects of arbitrary
* classes can be passed to exception constructors and will, subsequently,
* be returned by this method.
*
* @param exception [Exception] The exception object
* @return [String | Object] The exception message
*/
static VALUE exception_message(DDTRACE_UNUSED VALUE _self, VALUE exception) {
return rb_ivar_get(exception, id_mesg);
}

void di_init(VALUE datadog_module) {
id_mesg = rb_intern("mesg");

VALUE di_module = rb_define_module_under(datadog_module, "DI");
rb_define_singleton_method(di_module, "all_iseqs", all_iseqs, 0);
rb_define_singleton_method(di_module, "exception_message", exception_message, 1);
}
7 changes: 5 additions & 2 deletions ext/libdatadog_api/init.c
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
#include <ruby.h>

#include "datadog_ruby_common.h"

#include "crashtracker.h"
#include "process_discovery.h"
#include "library_config.h"
#include "feature_flags.h"
#include "library_config.h"
#include "process_discovery.h"

void ddsketch_init(VALUE core_module);
void di_init(VALUE datadog_module);

void DDTRACE_EXPORT Init_libdatadog_api(void) {
VALUE datadog_module = rb_define_module("Datadog");
Expand All @@ -21,4 +23,5 @@ void DDTRACE_EXPORT Init_libdatadog_api(void) {
library_config_init(core_module);
ddsketch_init(core_module);
feature_flags_init(core_module);
di_init(datadog_module);
}
26 changes: 26 additions & 0 deletions lib/datadog/di.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,32 @@ def enabled?
Datadog.configuration.dynamic_instrumentation.enabled
end

# Returns iseqs that correspond to loaded files (filtering out eval'd code).
#
# There are several types of iseqs returned by +all_iseqs+:
#
# 1. Eval'd code — these have a nil +absolute_path+ and are filtered out here.
# 2. Whole-file iseqs — have +absolute_path+ set and +first_lineno+ of 0.
# Only available for a subset of loaded files (the full-file iseq may be
# garbage collected after loading completes). Easiest to work with since
# we just match the file path to the probe specification.
# 3. Per-method iseqs — have +absolute_path+ set and +first_lineno+ > 0.
# Often the only iseqs available for third-party code. Require identifying
# the correct iseq containing the target line, which may involve examining
# the iseq's +trace_points+ since +define_method+ can create nested,
# non-contiguous line ranges.
#
# Note: the same line of code can appear in multiple iseqs (e.g. when
# +define_method+ is used inside a method). DI treats this as an error
# since a probe must resolve to exactly one code location.
#
# @return [Array<RubyVM::InstructionSequence>] iseqs with non-nil +absolute_path+
def file_iseqs
all_iseqs.select do |iseq|
iseq.absolute_path
end
end

# This method is called from DI Remote handler to issue DI operations
# to the probe manager (add or remove probes).
#
Expand Down
4 changes: 4 additions & 0 deletions lib/datadog/di/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ def environment_supported?(settings, logger)
logger.warn("di: cannot enable dynamic instrumentation: Ruby 2.6+ is required, but running on #{RUBY_VERSION}")
return false
end
unless DI.respond_to?(:exception_message)
logger.warn("di: cannot enable dynamic instrumentation: C extension is not available")
return false
end
true
end
end
Expand Down
11 changes: 9 additions & 2 deletions lib/datadog/di/instrumenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,16 @@ def hook_method(probe, responder)
caller_locations: caller_locs,
return_value: rv, duration: duration, exception: exc,)

responder.probe_executed_callback(context)
begin
responder.probe_executed_callback(context)

instrumenter.send(:check_and_disable_if_exceeded, probe, responder, di_start_time, di_duration)
rescue => di_exc
raise if settings.dynamic_instrumentation.internal.propagate_all_exceptions

instrumenter.send(:check_and_disable_if_exceeded, probe, responder, di_start_time, di_duration)
instrumenter.logger.debug { "di: unhandled exception in method probe: #{di_exc.class}: #{di_exc}" }
instrumenter.telemetry&.report(di_exc, description: "Unhandled exception in method probe")
end

if exc
raise exc
Expand Down
68 changes: 67 additions & 1 deletion lib/datadog/di/probe_notification_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ def build_executed(context)
NANOSECONDS = 1_000_000_000
MILLISECONDS = 1000

# Matches Ruby backtrace frame format: "/path/file.rb:42:in `method_name'"
# Captures: $1 = file path, $2 = line number, $3 = method name
BACKTRACE_FRAME_PATTERN = /\A(.+):(\d+):in\s+[`'](.+)'\z/

def build_snapshot(context)
probe = context.probe

Expand All @@ -79,7 +83,7 @@ def build_snapshot(context)
},
return: {
arguments: return_arguments,
throwable: nil,
throwable: context.exception ? serialize_throwable(context.exception) : nil,
},
}
elsif probe.line?
Expand Down Expand Up @@ -153,6 +157,68 @@ def build_status(probe, message:, status:, exception: nil)

private

# Serializes an exception for the throwable field in snapshot captures.
#
# Uses the C extension's exception_message to get the original message
# without invoking any Ruby-level message method override, which
# could be customer code.
#
# Caveats:
#
# 1. The value returned by exception_message is not guaranteed to be
# a string — it is whatever was passed to the Exception constructor.
# Calling .to_s on an arbitrary object would invoke customer code,
# violating DI's constraint of never executing customer methods
# during instrumentation. We only use the value directly when it
# is a String; for non-string values we return a redacted
# placeholder (reporting the class name would duplicate the
# exception type already present in the :type field).
#
# 2. Custom exception classes may not store a meaningful message via
# the constructor (e.g. they may compute it in an overridden
# +message+ method). In such cases exception_message may return
# nil or an unrelated constructor argument. This is acceptable:
# we still report the exception type, and a missing/wrong message
# is better than invoking customer code or reporting nothing.
#
# @param exception [Exception] the exception to serialize
# @return [Hash{Symbol => String?}] hash with :type and :message keys
def serialize_throwable(exception)
msg = DI.exception_message(exception)
message = if msg.nil? || String === msg
msg
else
# Non-string constructor argument — return a redacted placeholder
# rather than calling .to_s which could be customer code.
# The exception class is already reported via the :type field.
'<REDACTED: not a string value>'
end
{
type: exception.class.name,
message: message,
stacktrace: format_backtrace(exception.backtrace),
Comment thread
p-datadog marked this conversation as resolved.
}
end

# Parses Ruby backtrace strings into the stack frame format
# expected by the Datadog UI.
#
# Ruby backtrace format: "/path/file.rb:42:in `method_name'"
#
# @param backtrace [Array<String>, nil] from Exception#backtrace
# @return [Array<Hash>, nil]
def format_backtrace(backtrace)
return [] if backtrace.nil?

backtrace.map do |frame|
if frame =~ BACKTRACE_FRAME_PATTERN
{fileName: $1, function: $3, lineNumber: $2.to_i}
else
{fileName: frame, function: '', lineNumber: 0}
end
end
end

def build_snapshot_base(context, evaluation_errors: [], captures: nil, message: nil)
probe = context.probe

Expand Down
Loading
Loading