Skip to content

Commit c8121f4

Browse files
authored
feat(crashtracking): unhandled exception reporting FFI (#1597)
# What does this PR do? Add FFI interface for reporting unhandled exception. Also adds tests for it. *The FFI examples test infra part is largely claude code driven* PR below on the stack: [feat(crashtracking): report unhandled exceptions](#1596) # Motivation What inspired you to submit this pull request? # Additional Notes Anything else we should know when reviewing? # How to test the change? Run ffi example tests Co-authored-by: gyuheon.oh <gyuheon.oh@datadoghq.com>
1 parent eb48c1a commit c8121f4

File tree

4 files changed

+311
-6
lines changed

4 files changed

+311
-6
lines changed

examples/ffi/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ set_vcruntime_link_type(telemetry_metrics ${VCRUNTIME_LINK_TYPE})
6565
if(NOT WIN32)
6666
add_executable(crashtracking crashtracking.c)
6767
target_link_libraries(crashtracking PRIVATE Datadog::Profiling)
68+
69+
add_executable(crashtracking_unhandled_exception crashtracking_unhandled_exception.c)
70+
target_link_libraries(crashtracking_unhandled_exception PRIVATE Datadog::Profiling)
6871
endif()
6972

7073
add_executable(trace_exporter trace_exporter.c)
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// FFI test for ddog_crasht_report_unhandled_exception.
5+
//
6+
// This test initializes the crashtracker (without a live signal handler),
7+
// builds a small runtime StackTrace, calls report_unhandled_exception, and
8+
// verifies that a crash report file is produced in the current directory.
9+
//
10+
// Usage:
11+
// crashtracking_unhandled_exception [receiver_binary_path]
12+
//
13+
// The receiver binary path may also be supplied via the
14+
// DDOG_CRASHT_TEST_RECEIVER environment variable. When run through
15+
// `cargo ffi-test` the variable is set automatically.
16+
17+
#include <datadog/common.h>
18+
#include <datadog/crashtracker.h>
19+
#include <stdio.h>
20+
#include <stdlib.h>
21+
#include <string.h>
22+
23+
static ddog_CharSlice slice(const char *s) {
24+
return (ddog_CharSlice){.ptr = s, .len = strlen(s)};
25+
}
26+
27+
static void handle_void(ddog_VoidResult result, const char *ctx) {
28+
if (result.tag != DDOG_VOID_RESULT_OK) {
29+
ddog_CharSlice msg = ddog_Error_message(&result.err);
30+
fprintf(stderr, "FAIL [%s]: %.*s\n", ctx, (int)msg.len, msg.ptr);
31+
ddog_Error_drop(&result.err);
32+
exit(EXIT_FAILURE);
33+
}
34+
}
35+
36+
static void push_named_frame(ddog_crasht_Handle_StackTrace *trace,
37+
const char *function_name, uintptr_t ip) {
38+
ddog_crasht_StackFrame_NewResult fr = ddog_crasht_StackFrame_new();
39+
if (fr.tag != DDOG_CRASHT_STACK_FRAME_NEW_RESULT_OK) {
40+
ddog_CharSlice msg = ddog_Error_message(&fr.err);
41+
fprintf(stderr, "FAIL [StackFrame_new]: %.*s\n", (int)msg.len, msg.ptr);
42+
ddog_Error_drop(&fr.err);
43+
exit(EXIT_FAILURE);
44+
}
45+
46+
ddog_crasht_Handle_StackFrame *frame =
47+
(ddog_crasht_Handle_StackFrame *)malloc(sizeof(*frame));
48+
if (!frame) {
49+
fputs("FAIL [malloc frame]\n", stderr);
50+
exit(EXIT_FAILURE);
51+
}
52+
*frame = fr.ok;
53+
54+
handle_void(ddog_crasht_StackFrame_with_function(frame, slice(function_name)),
55+
"StackFrame_with_function");
56+
if (ip != 0) {
57+
handle_void(ddog_crasht_StackFrame_with_ip(frame, ip),
58+
"StackFrame_with_ip");
59+
}
60+
61+
/* push_frame consumes the frame */
62+
handle_void(ddog_crasht_StackTrace_push_frame(trace, frame, /*incomplete=*/true),
63+
"StackTrace_push_frame");
64+
free(frame);
65+
}
66+
67+
// Entry point
68+
int main(int argc, char **argv) {
69+
const char *receiver_path = NULL;
70+
if (argc >= 2) {
71+
receiver_path = argv[1];
72+
} else {
73+
receiver_path = getenv("DDOG_CRASHT_TEST_RECEIVER");
74+
}
75+
if (!receiver_path || receiver_path[0] == '\0') {
76+
fputs("FAIL: receiver binary path not provided.\n"
77+
" Pass it as argv[1] or set DDOG_CRASHT_TEST_RECEIVER.\n",
78+
stderr);
79+
return EXIT_FAILURE;
80+
}
81+
82+
static const char output_file[] = "crashreport_unhandled_exception.json";
83+
static const char stderr_file[] = "crashreport_unhandled_exception.stderr";
84+
static const char stdout_file[] = "crashreport_unhandled_exception.stdout";
85+
86+
// Forward the dynamic-linker search path to the receiver process.
87+
// The receiver is execve'd with an explicit environment so it does not
88+
// inherit the parent's env automatically. The variable name differs by OS:
89+
// Linux / ELF → LD_LIBRARY_PATH
90+
// macOS → DYLD_LIBRARY_PATH
91+
#ifdef __APPLE__
92+
const char *ld_search_path_var = "DYLD_LIBRARY_PATH";
93+
#else
94+
const char *ld_search_path_var = "LD_LIBRARY_PATH";
95+
#endif
96+
const char *ld_library_path = getenv(ld_search_path_var);
97+
ddog_crasht_EnvVar env_vars[1];
98+
ddog_crasht_Slice_EnvVar env_slice = {.ptr = NULL, .len = 0};
99+
if (ld_library_path && ld_library_path[0] != '\0') {
100+
env_vars[0].key = slice(ld_search_path_var);
101+
env_vars[0].val = slice(ld_library_path);
102+
env_slice.ptr = env_vars;
103+
env_slice.len = 1;
104+
}
105+
106+
ddog_crasht_ReceiverConfig receiver_config = {
107+
.path_to_receiver_binary = slice(receiver_path),
108+
.optional_stderr_filename = slice(stderr_file),
109+
.optional_stdout_filename = slice(stdout_file),
110+
.env = env_slice,
111+
};
112+
113+
struct ddog_Endpoint *endpoint =
114+
ddog_endpoint_from_filename(slice(output_file));
115+
116+
struct ddog_crasht_Slice_CInt signals = ddog_crasht_default_signals();
117+
ddog_crasht_Config config = {
118+
.create_alt_stack = false,
119+
.endpoint = endpoint,
120+
.resolve_frames = DDOG_CRASHT_STACKTRACE_COLLECTION_DISABLED,
121+
.signals = {.ptr = signals.ptr, .len = signals.len},
122+
};
123+
124+
ddog_crasht_Metadata metadata = {
125+
.library_name = slice("crashtracking-ffi-test"),
126+
.library_version = slice("0.0.0"),
127+
.family = slice("native"),
128+
.tags = NULL,
129+
};
130+
131+
handle_void(ddog_crasht_init(config, receiver_config, metadata),
132+
"ddog_crasht_init");
133+
ddog_endpoint_drop(endpoint);
134+
135+
// Build a runtime StackTrace with two synthetic frames.
136+
ddog_crasht_StackTrace_NewResult tr = ddog_crasht_StackTrace_new();
137+
if (tr.tag != DDOG_CRASHT_STACK_TRACE_NEW_RESULT_OK) {
138+
ddog_CharSlice msg = ddog_Error_message(&tr.err);
139+
fprintf(stderr, "FAIL [StackTrace_new]: %.*s\n", (int)msg.len, msg.ptr);
140+
ddog_Error_drop(&tr.err);
141+
return EXIT_FAILURE;
142+
}
143+
144+
ddog_crasht_Handle_StackTrace *trace =
145+
(ddog_crasht_Handle_StackTrace *)malloc(sizeof(*trace));
146+
if (!trace) {
147+
fputs("FAIL [malloc trace]\n", stderr);
148+
return EXIT_FAILURE;
149+
}
150+
*trace = tr.ok;
151+
152+
push_named_frame(trace, "com.example.MyApp.processRequest", 0x1000);
153+
push_named_frame(trace, "com.example.runtime.EventLoop.run", 0x2000);
154+
push_named_frame(trace, "com.example.runtime.main", 0x3000);
155+
156+
handle_void(ddog_crasht_StackTrace_set_complete(trace),
157+
"StackTrace_set_complete");
158+
159+
// Report the unhandled exception. This call:
160+
// - spawns the receiver process,
161+
// - sends the crash report over the socket,
162+
// - waits for the receiver to finish writing the report,
163+
// - returns Ok on success.
164+
handle_void(
165+
ddog_crasht_report_unhandled_exception(
166+
slice("com.example.UncaughtRuntimeException"),
167+
slice("Something went very wrong in the runtime"),
168+
trace),
169+
"ddog_crasht_report_unhandled_exception");
170+
171+
free(trace);
172+
173+
// Verify a report file was produced.
174+
FILE *f = fopen(output_file, "r");
175+
if (!f) {
176+
fprintf(stderr, "FAIL: expected crash report at '%s' but file not found\n",
177+
output_file);
178+
return EXIT_FAILURE;
179+
}
180+
fclose(f);
181+
182+
printf("PASS: crash report written to '%s'\n", output_file);
183+
return EXIT_SUCCESS;
184+
}
185+

libdd-crashtracker-ffi/src/collector/mod.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ pub use additional_tags::*;
1010
pub use counters::*;
1111
pub use datatypes::*;
1212
use function_name::named;
13-
use libdd_common_ffi::{wrap_with_void_ffi_result, Slice, VoidResult};
14-
use libdd_crashtracker::{CrashtrackerReceiverConfig, DEFAULT_SYMBOLS};
13+
use libdd_common_ffi::slice::AsBytes;
14+
use libdd_common_ffi::{wrap_with_void_ffi_result, CharSlice, Handle, Slice, ToInner, VoidResult};
15+
use libdd_crashtracker::{CrashtrackerReceiverConfig, StackTrace, DEFAULT_SYMBOLS};
1516
pub use spans::*;
1617

1718
#[no_mangle]
@@ -177,3 +178,56 @@ pub unsafe extern "C" fn ddog_crasht_init_without_receiver(
177178
pub extern "C" fn ddog_crasht_default_signals() -> Slice<'static, libc::c_int> {
178179
Slice::new(&DEFAULT_SYMBOLS)
179180
}
181+
182+
#[no_mangle]
183+
#[must_use]
184+
#[named]
185+
/// Report an unhandled exception as a crash event.
186+
///
187+
/// This function sends a crash report for an unhandled exception detected
188+
/// by the runtime. It is intended to be called when the process is in a
189+
/// terminal state due to an unhandled exception.
190+
///
191+
/// # Parameters
192+
/// - `error_type`: Optional type/class of the exception (e.g. "NullPointerException"). Pass empty
193+
/// CharSlice for unknown.
194+
/// - `error_message`: Optional error message. Pass empty CharSlice for no message.
195+
/// - `runtime_stack`: Stack trace from the runtime. Consumed by this call.
196+
///
197+
/// If the crash-tracker has not been initialized, this function is a no-op.
198+
///
199+
/// # Side effects
200+
/// This function disables the signal-based crash handler before performing
201+
/// any work. This means that if the process receives a fatal signal (SIGSEGV)
202+
/// during or after this call, the crashtracker will not produce a
203+
/// second crash report. The previous signal handler (if any) will still be
204+
/// chained.
205+
///
206+
/// # Failure mode
207+
/// If a fatal signal occurs while this function is in progress, the calling
208+
/// process is in an unrecoverable state; the crashtracker cannot report the
209+
/// secondary fault and the caller's own signal handler (if any) will execute
210+
/// in a potentially corrupted context. Callers should treat this function as a
211+
/// terminal operation and exit shortly after it returns.
212+
///
213+
/// # Safety
214+
/// Crash-tracking functions are not reentrant.
215+
/// No other crash-handler functions should be called concurrently.
216+
/// The `runtime_stack` handle must be valid and will be consumed.
217+
pub unsafe extern "C" fn ddog_crasht_report_unhandled_exception(
218+
error_type: CharSlice,
219+
error_message: CharSlice,
220+
mut runtime_stack: *mut Handle<StackTrace>,
221+
) -> VoidResult {
222+
wrap_with_void_ffi_result!({
223+
let error_type_opt = error_type.try_to_string_option()?;
224+
let error_message_opt = error_message.try_to_string_option()?;
225+
let stack = *runtime_stack.take()?;
226+
227+
libdd_crashtracker::report_unhandled_exception(
228+
error_type_opt.as_deref(),
229+
error_message_opt.as_deref(),
230+
stack,
231+
)?;
232+
})
233+
}

tools/src/bin/ffi_test.rs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,57 @@ fn skip_examples() -> &'static HashMap<&'static str, &'static str> {
128128
})
129129
}
130130

131+
/// Per-test environment variables. The runner sets these before spawning
132+
/// the test executable so that tests which need external resources (e.g. the
133+
/// receiver binary) can find them without hard-coding paths.
134+
fn per_test_env(name: &str, project_root: &Path) -> Vec<(String, String)> {
135+
match name {
136+
"crashtracking_unhandled_exception" => {
137+
// The receiver binary and shared library may live in either
138+
// "release/" (local/ffi_test build) or "artifacts/" (CI pre-built).
139+
// Check both and use whichever exists.
140+
let make_paths = |dir: &str| {
141+
let base = project_root.join(dir);
142+
(
143+
base.join("bin").join("libdatadog-crashtracking-receiver"),
144+
base.join("lib"),
145+
)
146+
};
147+
let (receiver, lib_dir) = ["release", "artifacts"]
148+
.iter()
149+
.map(|dir| make_paths(dir))
150+
.find(|(bin, _)| bin.exists())
151+
.unwrap_or_else(|| make_paths("release"));
152+
153+
// The C test binary is dynamically linked against libdatadog_profiling.{so,dylib}
154+
// which is not on the system library path. Set the platform-specific linker
155+
// search path so the binary can load, and the C test forwards it via getenv()
156+
// into the receiver's explicit execve environment.
157+
// Linux → LD_LIBRARY_PATH
158+
// macOS → DYLD_LIBRARY_PATH
159+
#[cfg(target_os = "macos")]
160+
let search_path_var = "DYLD_LIBRARY_PATH";
161+
#[cfg(not(target_os = "macos"))]
162+
let search_path_var = "LD_LIBRARY_PATH";
163+
164+
let lib_path = match std::env::var(search_path_var) {
165+
Ok(existing) if !existing.is_empty() => {
166+
format!("{}:{}", lib_dir.display(), existing)
167+
}
168+
_ => lib_dir.display().to_string(),
169+
};
170+
vec![
171+
(
172+
"DDOG_CRASHT_TEST_RECEIVER".to_string(),
173+
receiver.display().to_string(),
174+
),
175+
(search_path_var.to_string(), lib_path),
176+
]
177+
}
178+
_ => vec![],
179+
}
180+
}
181+
131182
fn expected_failures() -> &'static HashMap<&'static str, &'static str> {
132183
static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
133184
MAP.get_or_init(|| {
@@ -287,11 +338,16 @@ fn setup_work_dir(project_root: &Path) -> Result<PathBuf> {
287338
}
288339

289340
/// Spawn a test process and return child with captured output handles
290-
fn spawn_test(exe_path: &Path, work_dir: &Path) -> Result<std::process::Child> {
341+
fn spawn_test(
342+
exe_path: &Path,
343+
work_dir: &Path,
344+
env_vars: &[(String, String)],
345+
) -> Result<std::process::Child> {
291346
Command::new(exe_path)
292347
.current_dir(work_dir)
293348
.stdout(Stdio::piped())
294349
.stderr(Stdio::piped())
350+
.envs(env_vars.iter().map(|(k, v)| (k, v)))
295351
.spawn()
296352
.with_context(|| format!("spawning {}", exe_path.display()))
297353
}
@@ -387,11 +443,18 @@ fn determine_status(
387443
}
388444
}
389445

390-
fn run_test(name: &str, exe_path: &Path, work_dir: &Path, timeout: Duration) -> TestResult {
446+
fn run_test(
447+
name: &str,
448+
exe_path: &Path,
449+
work_dir: &Path,
450+
project_root: &Path,
451+
timeout: Duration,
452+
) -> TestResult {
391453
let is_expected_failure = expected_failures().contains_key(name);
454+
let env_vars = per_test_env(name, project_root);
392455
let start = Instant::now();
393456

394-
let child = match spawn_test(exe_path, work_dir) {
457+
let child = match spawn_test(exe_path, work_dir, &env_vars) {
395458
Ok(c) => c,
396459
Err(e) => {
397460
return TestResult {
@@ -507,7 +570,7 @@ fn run_examples(
507570
continue;
508571
}
509572

510-
let result = run_test(name, exe, &work_dir, timeout);
573+
let result = run_test(name, exe, &work_dir, project_root, timeout);
511574
result.print();
512575
results.push(result);
513576
}

0 commit comments

Comments
 (0)