diff --git a/ddtrace/internal/core/crashtracking.py b/ddtrace/internal/core/crashtracking.py index 7804d9fc739..06b204a0bf2 100644 --- a/ddtrace/internal/core/crashtracking.py +++ b/ddtrace/internal/core/crashtracking.py @@ -26,6 +26,8 @@ from ddtrace.internal.native._native import crashtracker_init from ddtrace.internal.native._native import crashtracker_on_fork from ddtrace.internal.native._native import crashtracker_status + from ddtrace.internal.native._native import crashtracker_register_native_runtime_callback + from ddtrace.internal.native._native import CallbackResult except ImportError: is_available = False @@ -169,3 +171,26 @@ def crashtracker_fork_handler(): print(f"Failed to start crashtracker: {e}", file=sys.stderr) return False return True + + +def register_runtime_callback() -> bool: + """ + Register the native runtime callback for stack collection during crashes. + + This should be called after crashtracker initialization to enable Python + runtime stack trace collection in crash reports. + + Returns: + bool: True if callback was registered successfully, False otherwise + """ + if not is_available: + return False + if not is_started(): + return False + + try: + result = crashtracker_register_native_runtime_callback() + return result == CallbackResult.Ok + except Exception as e: + print(f"Failed to register runtime callback: {e}", file=sys.stderr) + return False diff --git a/experimental_debug.json b/experimental_debug.json new file mode 100644 index 00000000000..22cdc471d4a --- /dev/null +++ b/experimental_debug.json @@ -0,0 +1,98 @@ +{ + "runtime_stack": { + "format": "Datadog Runtime Callback 1.0", + "frames": [ + { + "function": "string_at", + "file": "/home/bits/.pyenv/versions/3.11.13/lib/python3.11/ctypes/__init__.py", + "line": 519 + }, + { + "function": "func16", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 679 + }, + { + "function": "func15", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 676 + }, + { + "function": "func14", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 673 + }, + { + "function": "func13", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 670 + }, + { + "function": "func12", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 667 + }, + { + "function": "func11", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 664 + }, + { + "function": "func10", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 661 + }, + { + "function": "func9", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 658 + }, + { + "function": "func8", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 655 + }, + { + "function": "func7", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 652 + }, + { + "function": "func6", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 649 + }, + { + "function": "func5", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 646 + }, + { + "function": "func4", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 643 + }, + { + "function": "func3", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 640 + }, + { + "function": "func2", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 637 + }, + { + "function": "func1", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 634 + }, + { + "function": "", + "file": "tests/internal/crashtracker/test_crashtracker.py", + "line": 699 + } + ], + "runtime_type": "unknown" + } +} \ No newline at end of file diff --git a/src/native/Cargo.lock b/src/native/Cargo.lock index d2055ea2f06..8e39b043726 100644 --- a/src/native/Cargo.lock +++ b/src/native/Cargo.lock @@ -219,8 +219,8 @@ dependencies = [ [[package]] name = "build_common" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "cbindgen", "serde", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "cc_utils" version = "0.1.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "cc", @@ -488,8 +488,8 @@ dependencies = [ [[package]] name = "data-pipeline" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "arc-swap", @@ -518,8 +518,8 @@ dependencies = [ [[package]] name = "datadog-alloc" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "allocator-api2", "libc", @@ -528,8 +528,8 @@ dependencies = [ [[package]] name = "datadog-crashtracker" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "backtrace", @@ -561,8 +561,8 @@ dependencies = [ [[package]] name = "datadog-ddsketch" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "prost", ] @@ -570,7 +570,7 @@ dependencies = [ [[package]] name = "datadog-library-config" version = "0.0.2" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "memfd", @@ -583,8 +583,8 @@ dependencies = [ [[package]] name = "datadog-profiling" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "bitmaps", @@ -613,8 +613,8 @@ dependencies = [ [[package]] name = "datadog-profiling-ffi" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "build_common", @@ -632,16 +632,16 @@ dependencies = [ [[package]] name = "datadog-profiling-protobuf" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "prost", ] [[package]] name = "datadog-trace-normalization" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "datadog-trace-protobuf", @@ -649,8 +649,8 @@ dependencies = [ [[package]] name = "datadog-trace-protobuf" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "prost", "serde", @@ -659,8 +659,8 @@ dependencies = [ [[package]] name = "datadog-trace-utils" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "bytes", @@ -684,8 +684,8 @@ dependencies = [ [[package]] name = "ddcommon" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "cc", @@ -717,8 +717,8 @@ dependencies = [ [[package]] name = "ddcommon-ffi" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "build_common", @@ -731,8 +731,8 @@ dependencies = [ [[package]] name = "ddtelemetry" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "base64", @@ -759,6 +759,7 @@ version = "0.1.0" dependencies = [ "anyhow", "build_common", + "cc", "data-pipeline", "datadog-crashtracker", "datadog-ddsketch", @@ -767,6 +768,7 @@ dependencies = [ "ddcommon", "pyo3", "pyo3-build-config", + "pyo3-ffi", ] [[package]] @@ -799,8 +801,8 @@ dependencies = [ [[package]] name = "dogstatsd-client" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "anyhow", "cadence", @@ -2244,8 +2246,8 @@ dependencies = [ [[package]] name = "tinybytes" -version = "21.0.0" -source = "git+https://github.com/DataDog/libdatadog?rev=v21.0.0#4e1d7bb865885b48b4671fabc6643f0b5386f832" +version = "20.0.0" +source = "git+https://github.com/DataDog/libdatadog?rev=488d39573f008957784aa4ef726fadb4bee5a302#488d39573f008957784aa4ef726fadb4bee5a302" dependencies = [ "serde", ] diff --git a/src/native/Cargo.toml b/src/native/Cargo.toml index 4cbf3fabbfc..1144623d99b 100644 --- a/src/native/Cargo.toml +++ b/src/native/Cargo.toml @@ -16,19 +16,21 @@ profiling = ["dep:datadog-profiling-ffi"] [dependencies] anyhow = { version = "1.0", optional = true } -datadog-crashtracker = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0", optional = true } -datadog-ddsketch = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0" } -datadog-library-config = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0" } -data-pipeline = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0" } -datadog-profiling-ffi = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0", optional = true, features = [ +datadog-crashtracker = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302", optional = true } +datadog-ddsketch = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302" } +datadog-library-config = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302" } +data-pipeline = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302" } +datadog-profiling-ffi = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302", optional = true, features = [ "cbindgen", ] } -ddcommon = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0" } +ddcommon = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302" } pyo3 = { version = "0.25", features = ["extension-module", "anyhow"] } +pyo3-ffi = "0.25" [build-dependencies] pyo3-build-config = "0.25" -build_common = { git = "https://github.com/DataDog/libdatadog", rev = "v21.0.0", features = [ +cc = "1.0" +build_common = { git = "https://github.com/DataDog/libdatadog", rev = "488d39573f008957784aa4ef726fadb4bee5a302", features = [ "cbindgen", ] } diff --git a/src/native/build.rs b/src/native/build.rs index 91f43089a84..58da2d5f483 100644 --- a/src/native/build.rs +++ b/src/native/build.rs @@ -5,4 +5,69 @@ fn main() { if cfg!(target_os = "macos") { pyo3_build_config::add_extension_module_link_args(); } + + // Compile the C wrapper for CPython internal APIs + // This file defines Py_BUILD_CORE and provides access to internal functions + + // Get Python include directory using the cross-compilation info + let include_dir = match std::env::var("PYO3_CROSS_INCLUDE_DIR") { + Ok(dir) => std::path::PathBuf::from(dir), + Err(_) => { + // Fallback to using Python's sysconfig + let output = std::process::Command::new("python") + .args([ + "-c", + "import sysconfig; print(sysconfig.get_path('include'))", + ]) + .output() + .expect("Failed to run python to get include directory"); + std::path::PathBuf::from(String::from_utf8(output.stdout).unwrap().trim()) + } + }; + + // Add internal headers path for CPython internal APIs + let internal_headers_dir = include_dir.join("internal"); + + cc::Build::new() + .file("cpython_internal.c") + .include(&include_dir) + .include(&internal_headers_dir) // Add internal headers directory + .define("Py_BUILD_CORE", "1") + .compile("cpython_internal"); + + // Tell rustc to link the compiled C library + println!("cargo:rustc-link-lib=static=cpython_internal"); + + // Force linking to libpython to access internal symbols + // PyO3 normally avoids linking to libpython on Unix, but we need it for internal APIs + if !cfg!(target_os = "macos") { + // Get Python version and library info + let output = std::process::Command::new("python3") + .args(["-c", "import sysconfig; version = sysconfig.get_config_var('VERSION'); ldlibrary = sysconfig.get_config_var('LDLIBRARY'); libdir = sysconfig.get_config_var('LIBDIR'); print(f'{version}:{ldlibrary}:{libdir}')"]) + .output() + .expect("Failed to get Python library info"); + + let version_info = String::from_utf8(output.stdout).unwrap(); + let parts: Vec<&str> = version_info.trim().split(':').collect(); + + if parts.len() == 3 { + let version = parts[0]; + let ldlibrary = parts[1]; + let libdir = parts[2]; + + // Add library directory to search path + println!("cargo:rustc-link-search=native={}", libdir); + + // Extract library name from LDLIBRARY (e.g., "libpython3.11.so" -> "python3.11") + if let Some(lib_name) = ldlibrary + .strip_prefix("lib") + .and_then(|s| s.strip_suffix(".so")) + { + println!("cargo:rustc-link-lib={}", lib_name); + } else { + // Fallback to version-based naming + println!("cargo:rustc-link-lib=python{}", version); + } + } + } } diff --git a/src/native/cpython_internal.c b/src/native/cpython_internal.c new file mode 100644 index 00000000000..f1dd0929adb --- /dev/null +++ b/src/native/cpython_internal.c @@ -0,0 +1,17 @@ +// CPython internal API wrapper +// This file defines Py_BUILD_CORE to access internal CPython functions +// and provides a safe C interface for Rust FFI + +#define Py_BUILD_CORE 1 +#include +#include + +const char *crashtracker_dump_traceback_threads(int fd, + PyInterpreterState *interp, + PyThreadState *current_tstate) { + return _Py_DumpTracebackThreads(fd, interp, current_tstate); +} + +PyThreadState *crashtracker_get_current_tstate(void) { + return PyGILState_GetThisThreadState(); +} diff --git a/src/native/cpython_internal.h b/src/native/cpython_internal.h new file mode 100644 index 00000000000..5d4a4927828 --- /dev/null +++ b/src/native/cpython_internal.h @@ -0,0 +1,26 @@ +// CPython internal API wrapper header +// This provides C function declarations for accessing CPython internal APIs + +#ifndef CPYTHON_INTERNAL_H +#define CPYTHON_INTERNAL_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Wrapper function to call _Py_DumpTracebackThreads +// Returns error message on failure, NULL on success +const char *crashtracker_dump_traceback_threads(int fd, + PyInterpreterState *interp, + PyThreadState *current_tstate); + +// Wrapper to get the current thread state safely during crashes +PyThreadState *crashtracker_get_current_tstate(void); + +#ifdef __cplusplus +} +#endif + +#endif // CPYTHON_INTERNAL_H \ No newline at end of file diff --git a/src/native/crashtracker.rs b/src/native/crashtracker.rs index f20c6906b3a..166324483f6 100644 --- a/src/native/crashtracker.rs +++ b/src/native/crashtracker.rs @@ -1,15 +1,38 @@ use anyhow; use std::collections::HashMap; +use std::ffi::{c_char, c_int, c_void}; +use std::ptr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Once; use std::time::Duration; +// Removed unused imports for debug logging use datadog_crashtracker::{ - CrashtrackerConfiguration, CrashtrackerReceiverConfig, Metadata, StacktraceCollection, + register_runtime_stack_callback, CallbackError, CrashtrackerConfiguration, + CrashtrackerReceiverConfig, Metadata, RuntimeStackFrame, StacktraceCollection, }; use ddcommon::Endpoint; use pyo3::prelude::*; +extern "C" { + fn crashtracker_dump_traceback_threads( + fd: c_int, + interp: *mut pyo3_ffi::PyInterpreterState, + current_tstate: *mut pyo3_ffi::PyThreadState, + ) -> *const c_char; + + fn crashtracker_get_current_tstate() -> *mut pyo3_ffi::PyThreadState; + + fn pipe(pipefd: *mut [c_int; 2]) -> c_int; + fn read(fd: c_int, buf: *mut c_void, count: usize) -> isize; + fn close(fd: c_int) -> c_int; + fn fcntl(fd: c_int, cmd: c_int, arg: c_int) -> c_int; +} + +// Constants for fcntl +const F_SETFL: c_int = 4; +const O_NONBLOCK: c_int = 0o4000; + pub trait RustWrapper { type Inner; const INNER_TYPE_NAME: &'static str; @@ -288,3 +311,322 @@ pub fn crashtracker_status() -> anyhow::Result { pub fn crashtracker_receiver() -> anyhow::Result<()> { datadog_crashtracker::receiver_entry_point_stdin() } + +/// Result type for runtime callback operations +#[pyclass( + eq, + eq_int, + name = "CallbackResult", + module = "datadog.internal._native" +)] +#[derive(Debug, PartialEq, Eq)] +pub enum CallbackResult { + Ok, + AlreadyRegistered, + NullCallback, + UnknownError, +} + +impl From for CallbackResult { + fn from(error: CallbackError) -> Self { + match error { + CallbackError::AlreadyRegistered => CallbackResult::AlreadyRegistered, + CallbackError::NullCallback => CallbackResult::NullCallback, + } + } +} + +/// Runtime-specific stack frame representation for FFI +/// +/// This struct is used to pass runtime stack frame information from language +/// runtimes to the crashtracker during crash handling. +#[pyclass(name = "RuntimeStackFrame", module = "datadog.internal._native")] +#[derive(Debug, Clone)] +pub struct RuntimeStackFramePy { + pub function_name: Option, + pub file_name: Option, + pub line_number: u32, + pub column_number: u32, + pub class_name: Option, + pub module_name: Option, +} + +#[pymethods] +impl RuntimeStackFramePy { + #[new] + fn new( + function_name: Option, + file_name: Option, + line_number: u32, + column_number: u32, + class_name: Option, + module_name: Option, + ) -> Self { + Self { + function_name, + file_name, + line_number, + column_number, + class_name, + module_name, + } + } + + #[getter] + fn get_function_name(&self) -> Option { + self.function_name.clone() + } + + #[getter] + fn get_file_name(&self) -> Option { + self.file_name.clone() + } + + #[getter] + fn get_line_number(&self) -> u32 { + self.line_number + } + + #[getter] + fn get_column_number(&self) -> u32 { + self.column_number + } + + #[getter] + fn get_class_name(&self) -> Option { + self.class_name.clone() + } + + #[getter] + fn get_module_name(&self) -> Option { + self.module_name.clone() + } +} + +// Constants for signal-safe operation +const MAX_FRAMES: usize = 64; +const MAX_STRING_LEN: usize = 256; +const MAX_TRACEBACK_SIZE: usize = 64 * 1024; // 64KB buffer for traceback text + +// Stack-allocated buffer for signal-safe string handling +struct StackBuffer { + data: [u8; MAX_STRING_LEN], + len: usize, +} + +impl StackBuffer { + const fn new() -> Self { + Self { + data: [0u8; MAX_STRING_LEN], + len: 0, + } + } + + fn as_ptr(&self) -> *const c_char { + self.data.as_ptr() as *const c_char + } + + fn set_from_str(&mut self, s: &str) { + let bytes = s.as_bytes(); + let copy_len = bytes.len().min(MAX_STRING_LEN - 1); + self.data[..copy_len].copy_from_slice(&bytes[..copy_len]); + self.data[copy_len] = 0; + self.len = copy_len; + } +} + +// Parse a single traceback line into frame information +// ' File "/path/to/file.py", line 42, in function_name' +fn parse_traceback_line( + line: &str, + function_buf: &mut StackBuffer, + file_buf: &mut StackBuffer, +) -> u32 { + let trimmed = line.trim(); + + // Look for the pattern: File "filename", line number, in function_name + if let Some(file_start) = trimmed.find('"') { + if let Some(file_end) = trimmed[file_start + 1..].find('"') { + let file_path = &trimmed[file_start + 1..file_start + 1 + file_end]; + file_buf.set_from_str(file_path); + + let after_file = &trimmed[file_start + file_end + 2..]; + if let Some(line_start) = after_file.find("line ") { + let line_part = &after_file[line_start + 5..]; + + // Try to find comma first ("line 42, in func") + let line_num = if let Some(line_end) = line_part.find(',') { + let line_str = line_part[..line_end].trim(); + line_str.parse::().unwrap_or(0) + } else { + // No comma, try space ("line 42 in func") + if let Some(space_pos) = line_part.find(' ') { + let line_str = line_part[..space_pos].trim(); + line_str.parse::().unwrap_or(0) + } else { + // Just numbers until end + let line_str = line_part.trim(); + line_str.parse::().unwrap_or(0) + } + }; + + // Look for function name + if let Some(in_pos) = after_file.find(" in ") { + let func_name = after_file[in_pos + 4..].trim(); + function_buf.set_from_str(func_name); + } else { + function_buf.set_from_str(""); + } + + return line_num; + } + } + } + + // Fallback parsing + function_buf.set_from_str(""); + file_buf.set_from_str(""); + 0 +} + +// Parse traceback text and emit frames +unsafe fn parse_and_emit_traceback( + traceback_text: &str, + emit_frame: unsafe extern "C" fn(*const RuntimeStackFrame), +) { + let lines: Vec<&str> = traceback_text.lines().collect(); + let mut frame_count = 0; + + for line in lines { + if frame_count >= MAX_FRAMES { + break; + } + + // Look for lines that start with " File " - these are stack frame lines + if line.trim_start().starts_with("File ") { + let mut function_buf = StackBuffer::new(); + let mut file_buf = StackBuffer::new(); + + let line_number = parse_traceback_line(line, &mut function_buf, &mut file_buf); + + let c_frame = RuntimeStackFrame { + function_name: function_buf.as_ptr(), + file_name: file_buf.as_ptr(), + line_number, + column_number: 0, + class_name: ptr::null(), + module_name: ptr::null(), + }; + + emit_frame(&c_frame); + frame_count += 1; + } + } +} + +unsafe fn dump_python_traceback_via_cpython_api( + emit_frame: unsafe extern "C" fn(*const RuntimeStackFrame), +) { + // Create a pipe to capture CPython internal traceback dump + let mut pipefd: [c_int; 2] = [0, 0]; + if pipe(&mut pipefd as *mut [c_int; 2]) != 0 { + emit_fallback_frame(emit_frame, ""); + return; + } + + let read_fd = pipefd[0]; + let write_fd = pipefd[1]; + + // Make the read end non-blocking + fcntl(read_fd, F_SETFL, O_NONBLOCK); + + // Get the current thread state safely - same approach as CPython's faulthandler + // SIGSEGV, SIGFPE, SIGABRT, SIGBUS and SIGILL are synchronous signals and + // are thus delivered to the thread that caused the fault. + let current_tstate = crashtracker_get_current_tstate(); + + // Call the CPython internal API via our C wrapper + // Pass NULL for interpreter state - let _Py_DumpTracebackThreads handle it internally + let error_msg = crashtracker_dump_traceback_threads(write_fd, ptr::null_mut(), current_tstate); + + close(write_fd); + + // Check for errors from _Py_DumpTracebackThreads + if !error_msg.is_null() { + close(read_fd); + let error_str = std::ffi::CStr::from_ptr(error_msg); + if let Ok(error_string) = error_str.to_str() { + emit_fallback_frame(emit_frame, error_string); + } else { + emit_fallback_frame(emit_frame, ""); + } + return; + } + + // Read the traceback output + let mut buffer = vec![0u8; MAX_TRACEBACK_SIZE]; + let bytes_read = read( + read_fd, + buffer.as_mut_ptr() as *mut c_void, + MAX_TRACEBACK_SIZE, + ); + + close(read_fd); + + if bytes_read > 0 { + buffer.truncate(bytes_read as usize); + if let Ok(traceback_text) = std::str::from_utf8(&buffer) { + parse_and_emit_traceback(traceback_text, emit_frame); + return; + } + } + + // If we get here, something went wrong with reading the output + emit_fallback_frame(emit_frame, ""); +} + +// Helper function to emit a fallback frame with error information +unsafe fn emit_fallback_frame( + emit_frame: unsafe extern "C" fn(*const RuntimeStackFrame), + error_msg: &str, +) { + let mut function_buf = StackBuffer::new(); + let mut file_buf = StackBuffer::new(); + function_buf.set_from_str(error_msg); + file_buf.set_from_str(""); + + let fallback_frame = RuntimeStackFrame { + function_name: function_buf.as_ptr(), + file_name: file_buf.as_ptr(), + line_number: 0, + column_number: 0, + class_name: ptr::null(), + module_name: ptr::null(), + }; + + emit_frame(&fallback_frame); +} + +unsafe extern "C" fn native_runtime_stack_callback( + emit_frame: unsafe extern "C" fn(*const RuntimeStackFrame), + _context: *mut c_void, +) { + // Use CPython internal API to dump traceback directly - more reliable than faulthandler + dump_python_traceback_via_cpython_api(emit_frame); +} + +/// Register the native runtime stack collection callback +/// +/// This function registers a native callback that directly collects Python runtime +/// stack traces without requiring Python callback functions. +/// +/// # Returns +/// - `CallbackResult::Ok` if registration succeeds +/// - `CallbackResult::AlreadyRegistered` if a callback is already registered +#[pyfunction(name = "crashtracker_register_native_runtime_callback")] +pub fn crashtracker_register_native_runtime_callback() -> CallbackResult { + match register_runtime_stack_callback(native_runtime_stack_callback, std::ptr::null_mut()) { + Ok(()) => CallbackResult::Ok, + Err(e) => e.into(), + } +} diff --git a/src/native/lib.rs b/src/native/lib.rs index a84ebf2c46a..f33f9defa2b 100644 --- a/src/native/lib.rs +++ b/src/native/lib.rs @@ -25,10 +25,16 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(crashtracker::crashtracker_init, m)?)?; m.add_function(wrap_pyfunction!(crashtracker::crashtracker_on_fork, m)?)?; m.add_function(wrap_pyfunction!(crashtracker::crashtracker_status, m)?)?; m.add_function(wrap_pyfunction!(crashtracker::crashtracker_receiver, m)?)?; + m.add_function(wrap_pyfunction!( + crashtracker::crashtracker_register_native_runtime_callback, + m + )?)?; } m.add_class::()?; m.add_class::()?; diff --git a/tests/internal/crashtracker/test_crashtracker.py b/tests/internal/crashtracker/test_crashtracker.py index b9380d7c925..d4f51ecf7ec 100644 --- a/tests/internal/crashtracker/test_crashtracker.py +++ b/tests/internal/crashtracker/test_crashtracker.py @@ -619,6 +619,101 @@ def test_crashtracker_echild_hang(): pytest.fail("Unexpected exception: %s" % e) +@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux only") +@pytest.mark.subprocess() +def test_crashtracker_runtime_callback(): + import ctypes + import os + + import tests.internal.crashtracker.utils as utils + + with utils.with_test_agent() as client: + pid = os.fork() + + def func1(): + return func2() + + def func2(): + return func3() + + def func3(): + return func4() + + def func4(): + return func5() + + def func5(): + return func6() + + def func6(): + return func7() + + def func7(): + return func8() + + def func8(): + return func9() + + def func9(): + return func10() + + def func10(): + return func11() + + def func11(): + return func12() + + def func12(): + return func13() + + def func13(): + return func14() + + def func14(): + return func15() + + def func15(): + return func16() + + def func16(): + ctypes.string_at(0) + sys.exit(-1) + + if pid == 0: + ct = utils.CrashtrackerWrapper(base_name="runtime_runtime_callback") + assert ct.start() + stdout_msg, stderr_msg = ct.logs() + assert not stdout_msg, stdout_msg + assert not stderr_msg, stderr_msg + + try: + from ddtrace.internal.core import crashtracking + + result = crashtracking.register_runtime_callback() + assert result, "Failed to register runtime runtime callback" + + except Exception as e: + print(f"Error registering runtime callback: {e}") + sys.exit(-1) + + func1() + + report = utils.get_crash_report(client) + + import json + try: + report_dict = json.loads(report["body"].decode('utf-8')) + message = report_dict["payload"][0]["message"] + message_dict = json.loads(message) + experimental = message_dict['experimental'] + + with open("experimental_debug.json", "w") as f: + json.dump(experimental, f, indent=2) + + except (json.JSONDecodeError, UnicodeDecodeError) as e: + print(f"Could not parse report as JSON: {e}") + + @pytest.mark.skipif(not sys.platform.startswith("linux"), reason="Linux only") @pytest.mark.subprocess() def test_crashtracker_no_zombies():