From 691b9d09ebbd7e62fd4175f80d2fb10ca054a81c Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Wed, 25 Feb 2026 14:14:01 +0100 Subject: [PATCH 1/7] Add unhandled exception libdatadog binding --- crates/crashtracker/src/lib.rs | 222 ++++++++++++++++++- test/crashtracker/app-seg-fault.js | 9 + test/crashtracker/app-unhandled-exception.js | 17 ++ test/crashtracker/app.js | 44 ---- test/crashtracker/index.js | 131 ++++++++--- test/crashtracker/test_utils.js | 43 ++++ 6 files changed, 382 insertions(+), 84 deletions(-) create mode 100644 test/crashtracker/app-seg-fault.js create mode 100644 test/crashtracker/app-unhandled-exception.js delete mode 100644 test/crashtracker/app.js create mode 100644 test/crashtracker/test_utils.js diff --git a/crates/crashtracker/src/lib.rs b/crates/crashtracker/src/lib.rs index a908776..b02fddb 100644 --- a/crates/crashtracker/src/lib.rs +++ b/crates/crashtracker/src/lib.rs @@ -1,9 +1,9 @@ -use napi::{Env, JsUnknown}; +use napi::{Env, JsObject, JsUnknown}; use napi_derive::napi; /// Ensures that if signals is empty, default signals are applied. /// This is necessary because NAPI deserialization bypasses the -/// CrashtrackerConfiguration::new() constructor where the default +/// CrashtrackerConfiguration::new() constructor where the default /// signals logic exists. fn apply_default_signals( config: libdd_crashtracker::CrashtrackerConfiguration, @@ -15,7 +15,7 @@ fn apply_default_signals( config.use_alt_stack(), config.endpoint().clone(), config.resolve_frames(), - vec![], // Empty vec will be replaced with default_signals() in new() in libdatadog + vec![], // Empty vec will be replaced with default_signals() in new() in libdatadog Some(config.timeout()), config.unix_socket_path().clone(), config.demangle_names(), @@ -27,7 +27,12 @@ fn apply_default_signals( } #[napi] -pub fn init(env: Env, config: JsUnknown, receiver_config: JsUnknown, metadata: JsUnknown) -> napi::Result<()> { +pub fn init( + env: Env, + config: JsUnknown, + receiver_config: JsUnknown, + metadata: JsUnknown, +) -> napi::Result<()> { let config: libdd_crashtracker::CrashtrackerConfiguration = env.from_js_value(config)?; let receiver_config = env.from_js_value(receiver_config)?; let metadata = env.from_js_value(metadata)?; @@ -40,7 +45,7 @@ pub fn init(env: Env, config: JsUnknown, receiver_config: JsUnknown, metadata: J } #[napi] -pub fn update_config (env: Env, config: JsUnknown) -> napi::Result<()> { +pub fn update_config(env: Env, config: JsUnknown) -> napi::Result<()> { let config: libdd_crashtracker::CrashtrackerConfiguration = env.from_js_value(config)?; let config = apply_default_signals(config); @@ -51,7 +56,7 @@ pub fn update_config (env: Env, config: JsUnknown) -> napi::Result<()> { } #[napi] -pub fn update_metadata (env: Env, metadata: JsUnknown) -> napi::Result<()> { +pub fn update_metadata(env: Env, metadata: JsUnknown) -> napi::Result<()> { let metadata = env.from_js_value(metadata)?; libdd_crashtracker::update_metadata(metadata).unwrap(); @@ -60,15 +65,216 @@ pub fn update_metadata (env: Env, metadata: JsUnknown) -> napi::Result<()> { } #[napi] -pub fn begin_profiler_serializing (_env: Env) -> napi::Result<()> { +pub fn begin_profiler_serializing(_env: Env) -> napi::Result<()> { let _ = libdd_crashtracker::begin_op(libdd_crashtracker::OpTypes::ProfilerSerializing); Ok(()) } #[napi] -pub fn end_profiler_serializing (_env: Env) -> napi::Result<()> { +pub fn end_profiler_serializing(_env: Env) -> napi::Result<()> { let _ = libdd_crashtracker::end_op(libdd_crashtracker::OpTypes::ProfilerSerializing); Ok(()) } + +fn get_optional_string_property(obj: &JsObject, key: &str) -> napi::Result> { + match obj.get_named_property::(key) { + Ok(val) => { + use napi::ValueType; + if val.get_type()? == ValueType::String { + let s: String = val.coerce_to_string()?.into_utf8()?.as_str()?.to_owned(); + if s.is_empty() { + Ok(None) + } else { + Ok(Some(s)) + } + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } +} + +fn parse_v8_stack(stack: &str) -> libdd_crashtracker::StackTrace { + let mut frames = Vec::new(); + + for line in stack.lines().skip(1) { + let line = line.trim(); + let line = match line.strip_prefix("at ") { + Some(rest) => rest, + None => continue, + }; + + let mut frame = libdd_crashtracker::StackFrame::new(); + + // Formats: + // "functionName (file:line:col)" + // "functionName (file:line)" + // "file:line:col" + // "file:line" + if let Some(paren_start) = line.rfind('(') { + let func_name = line[..paren_start].trim(); + if !func_name.is_empty() { + frame.function = Some(func_name.to_string()); + } + let location = line[paren_start + 1..].trim_end_matches(')'); + parse_location(location, &mut frame); + } else { + parse_location(line, &mut frame); + } + + frames.push(frame); + } + + libdd_crashtracker::StackTrace::from_frames(frames, false) +} + +fn parse_location(location: &str, frame: &mut libdd_crashtracker::StackFrame) { + // location is "file:line:col" or "file:line" or just "native" etc. + // The file portion may contain ":" ("node:internal/...") + // so we split from the right. + let parts: Vec<&str> = location.rsplitn(3, ':').collect(); + match parts.len() { + 3 => { + // col, line, file + frame.column = parts[0].parse().ok(); + frame.line = parts[1].parse().ok(); + frame.file = Some(parts[2].to_string()); + } + 2 => { + if let Ok(line_num) = parts[0].parse::() { + frame.line = Some(line_num); + frame.file = Some(parts[1].to_string()); + } else { + frame.file = Some(location.to_string()); + } + } + _ => { + frame.file = Some(location.to_string()); + } + } +} + +#[napi] +pub fn report_unhandled_exception(_env: Env, error: JsObject) -> napi::Result<()> { + let exception_type = get_optional_string_property(&error, "name")?; + let exception_message = get_optional_string_property(&error, "message")?; + let stack_string = get_optional_string_property(&error, "stack")?; + + let stacktrace = match &stack_string { + Some(s) => parse_v8_stack(s), + None => libdd_crashtracker::StackTrace::empty(), + }; + + libdd_crashtracker::report_unhandled_exception( + exception_type.as_deref(), + exception_message.as_deref(), + stacktrace, + ) + .unwrap(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_v8_stack_typical_error() { + let stack = "\ +TypeError: Cannot read properties of undefined (reading 'foo') + at Object.method (/app/src/index.js:10:15) + at Module._compile (node:internal/modules/cjs/loader:1234:14) + at /app/src/helper.js:5:3"; + + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 3); + assert!(!trace.incomplete); + + assert_eq!(trace.frames[0].function.as_deref(), Some("Object.method")); + assert_eq!(trace.frames[0].file.as_deref(), Some("/app/src/index.js")); + assert_eq!(trace.frames[0].line, Some(10)); + assert_eq!(trace.frames[0].column, Some(15)); + + assert_eq!(trace.frames[1].function.as_deref(), Some("Module._compile")); + assert_eq!( + trace.frames[1].file.as_deref(), + Some("node:internal/modules/cjs/loader") + ); + assert_eq!(trace.frames[1].line, Some(1234)); + assert_eq!(trace.frames[1].column, Some(14)); + + assert_eq!(trace.frames[2].function, None); + assert_eq!(trace.frames[2].file.as_deref(), Some("/app/src/helper.js")); + assert_eq!(trace.frames[2].line, Some(5)); + assert_eq!(trace.frames[2].column, Some(3)); + } + + #[test] + fn test_parse_v8_stack_anonymous_and_native() { + let stack = "\ +Error: boom + at :1:1 + at native"; + + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 2); + + assert_eq!(trace.frames[0].file.as_deref(), Some("")); + assert_eq!(trace.frames[0].line, Some(1)); + assert_eq!(trace.frames[0].column, Some(1)); + + assert_eq!(trace.frames[1].file.as_deref(), Some("native")); + assert_eq!(trace.frames[1].line, None); + } + + #[test] + fn test_parse_v8_stack_empty() { + let stack = "Error: something"; + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 0); + assert!(!trace.incomplete); + } + + #[test] + fn test_parse_location_file_line_col() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("/app/index.js:42:7", &mut frame); + assert_eq!(frame.file.as_deref(), Some("/app/index.js")); + assert_eq!(frame.line, Some(42)); + assert_eq!(frame.column, Some(7)); + } + + #[test] + fn test_parse_location_node_internal() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("node:internal/modules/cjs/loader:1234:14", &mut frame); + assert_eq!( + frame.file.as_deref(), + Some("node:internal/modules/cjs/loader") + ); + assert_eq!(frame.line, Some(1234)); + assert_eq!(frame.column, Some(14)); + } + + #[test] + fn test_parse_location_no_column() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("/app/index.js:42", &mut frame); + assert_eq!(frame.file.as_deref(), Some("/app/index.js")); + assert_eq!(frame.line, Some(42)); + assert_eq!(frame.column, None); + } + + #[test] + fn test_parse_location_bare_path() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("native", &mut frame); + assert_eq!(frame.file.as_deref(), Some("native")); + assert_eq!(frame.line, None); + assert_eq!(frame.column, None); + } +} diff --git a/test/crashtracker/app-seg-fault.js b/test/crashtracker/app-seg-fault.js new file mode 100644 index 0000000..a1471f6 --- /dev/null +++ b/test/crashtracker/app-seg-fault.js @@ -0,0 +1,9 @@ +'use strict' + +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') +const { initTestCrashtracker } = require('./test_utils') + +initTestCrashtracker() +crashtracker.beginProfilerSerializing() +require('@datadog/segfaultify').segfaultify() diff --git a/test/crashtracker/app-unhandled-exception.js b/test/crashtracker/app-unhandled-exception.js new file mode 100644 index 0000000..36b6ee0 --- /dev/null +++ b/test/crashtracker/app-unhandled-exception.js @@ -0,0 +1,17 @@ +'use strict' + +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') +const { initTestCrashtracker } = require('./test_utils') + +initTestCrashtracker() +function myFaultyFunction () { + throw new TypeError('something went wrong') +} + +crashtracker.beginProfilerSerializing() +try { + myFaultyFunction() +} catch (e) { + crashtracker.reportUnhandledException(e) +} diff --git a/test/crashtracker/app.js b/test/crashtracker/app.js deleted file mode 100644 index 5ec4b86..0000000 --- a/test/crashtracker/app.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict' - -const libdatadog = require('../..') -const crashtracker = libdatadog.load('crashtracker') - -crashtracker.init({ - additional_files: [], - create_alt_stack: true, - use_alt_stack: true, - endpoint: { - url: { - scheme: 'http', - authority: `127.0.0.1:${process.env.PORT || 8126}`, - path_and_query: '' - }, - timeout_ms: 3000 - }, - timeout: { secs: 3, nanos: 0 }, - resolve_frames: 'EnabledWithInprocessSymbols', - wait_for_receiver: true, - demangle_names: false, - signals: [] -}, { - args: [], - env: [], - path_to_receiver_binary: libdatadog.find('crashtracker-receiver', true), - stderr_filename: 'stderr.log', - stdout_filename: 'stdout.log', -}, { - library_name: 'dd-trace-js', - library_version: '6.0.0-pre', - family: 'javascript', - tags: [ - 'language:javascript', - 'runtime:nodejs', - 'runtime-id:8a8fef6433a849b3bc3171198831d102', - 'library_version:6.0.0-pre', - 'is_crash:true', - 'severity:crash' - ] -}) - -crashtracker.beginProfilerSerializing() -require('@datadog/segfaultify').segfaultify() diff --git a/test/crashtracker/index.js b/test/crashtracker/index.js index 92454d8..683bd59 100644 --- a/test/crashtracker/index.js +++ b/test/crashtracker/index.js @@ -1,7 +1,6 @@ 'use strict' const { execSync, exec } = require('child_process') -const { inspect } = require('util') const cwd = __dirname const stdio = ['inherit', 'inherit', 'inherit'] @@ -26,7 +25,9 @@ let timeout = setTimeout(() => { execSync('cat stderr.log', opts) throw new Error('No crash report received before timing out.') -}, 10000) // TODO: reduce this when the receiver no longer locks up +}, 10000) + +let currentTest = null app.use(bodyParser.json()) @@ -41,42 +42,108 @@ app.post('/telemetry/proxy/api/v2/apmtelemetry', (req, res) => { return } - clearTimeout(timeout) - - server.close(() => { - const stackTrace = JSON.parse(logPayload.message).error.stack.frames - - const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) + if (currentTest) { + currentTest(logPayload, tags) + } +}) - if (existsSync('/etc/alpine-release')) { - // TODO: Remove this when supported. - console.log('Received crash report. Skipping stack trace test since it is currently unsupported for Alpine.') - } else if (boomFrame) { - console.log('Stack frame for crashing function successfully received by the mock agent.') - } else { - throw new Error('Could not find a stack frame for the crashing function.') +function runSegfaultTest (PORT) { + return new Promise((resolve, reject) => { + currentTest = (logPayload, tags) => { + currentTest = null + const stackTrace = JSON.parse(logPayload.message).error.stack.frames + + const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) + + if (existsSync('/etc/alpine-release')) { + // TODO: Remove this when supported. + console.log('Received crash report. Skipping stack trace test since it is currently unsupported for Alpine.') + } else if (boomFrame) { + console.log('Stack frame for crashing function successfully received by the mock agent.') + } else { + return reject(new Error('Could not find a stack frame for the crashing function.')) + } + + if (tags.includes('profiler_serializing:1')) { + console.log('Stack trace was marked as happened during profile serialization.') + } else { + return reject(new Error('Stack trace was not marked as happening during profile serialization.')) + } + + resolve() } - if (tags.includes('profiler_serializing:1')) { - console.log('Stack trace was marked as happened during profile serialization.') - } else { - throw new Error('Stack trace was not marked as happening during profile serialization.') + exec('node app-seg-fault', { + ...opts, + env: { ...process.env, PORT } + }, e => { + if (e && e.signal !== 'SIGSEGV' && e.code !== 139 && e.status !== 139) { + reject(e) + } + }) + }) +} + +function runUnhandledExceptionTest (PORT) { + return new Promise((resolve, reject) => { + rmSync(path.join(cwd, 'stdout.log'), { force: true }) + rmSync(path.join(cwd, 'stderr.log'), { force: true }) + + currentTest = (logPayload) => { + currentTest = null + const crashReport = JSON.parse(logPayload.message) + const stackTrace = crashReport.error.stack.frames + const errorMessage = crashReport.error.message + const errorKind = crashReport.error.kind + + if (errorKind === 'UnhandledException') { + console.log('Error kind correctly reported as UnhandledException.') + } else { + return reject(new Error(`Expected error kind "UnhandledException" but got "${errorKind}".`)) + } + + if (errorMessage.includes('TypeError') && errorMessage.includes('something went wrong')) { + console.log('Exception type and message correctly captured.') + } else { + return reject(new Error(`Error message did not contain expected content: "${errorMessage}".`)) + } + + const faultyFrame = stackTrace.find(frame => + frame.function && frame.function.includes('myFaultyFunction') + ) + + if (faultyFrame) { + console.log('Stack frame for myFaultyFunction successfully received by the mock agent.') + } else { + return reject(new Error('Could not find a stack frame for myFaultyFunction.')) + } + + resolve() } + + exec('node app-unhandled-exception', { + ...opts, + env: { ...process.env, PORT } + }, e => { + if (e) { + // tolerate non-zero exit since reportUnhandledException disables the crash handler + } + }) }) -}) +} -const server = app.listen(() => { +const server = app.listen(async () => { const PORT = server.address().port - exec('node app', { - ...opts, - env: { - ...process.env, - PORT - } - }, e => { - if (e.signal !== 'SIGSEGV' && e.code !== 139 && e.status !== 139) { - throw e - } - }) + try { + await runSegfaultTest(PORT) + await runUnhandledExceptionTest(PORT) + } catch (e) { + clearTimeout(timeout) + server.close(() => { throw e }) + return + } + + clearTimeout(timeout) + server.close() }) diff --git a/test/crashtracker/test_utils.js b/test/crashtracker/test_utils.js new file mode 100644 index 0000000..5342db9 --- /dev/null +++ b/test/crashtracker/test_utils.js @@ -0,0 +1,43 @@ +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') + +function initTestCrashtracker() { + crashtracker.init({ + additional_files: [], + create_alt_stack: true, + use_alt_stack: true, + endpoint: { + url: { + scheme: 'http', + authority: `127.0.0.1:${process.env.PORT || 8126}`, + path_and_query: '' + }, + timeout_ms: 3000 + }, + timeout: { secs: 3, nanos: 0 }, + resolve_frames: 'EnabledWithInprocessSymbols', + wait_for_receiver: true, + demangle_names: false, + signals: [] + }, { + args: [], + env: [], + path_to_receiver_binary: libdatadog.find('crashtracker-receiver', true), + stderr_filename: 'stderr.log', + stdout_filename: 'stdout.log', + }, { + library_name: 'dd-trace-js', + library_version: '6.0.0-pre', + family: 'javascript', + tags: [ + 'language:javascript', + 'runtime:nodejs', + 'runtime-id:8a8fef6433a849b3bc3171198831d102', + 'library_version:6.0.0-pre', + 'is_crash:true', + 'severity:crash' + ] + }) +} + +module.exports = { initTestCrashtracker } From fbb872ef3b71bd24c92f87c1d5aa268a86b43ca0 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Wed, 25 Feb 2026 19:27:11 +0100 Subject: [PATCH 2/7] Use process.on() in the test --- test/crashtracker/app-unhandled-exception.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/crashtracker/app-unhandled-exception.js b/test/crashtracker/app-unhandled-exception.js index 36b6ee0..2f371ee 100644 --- a/test/crashtracker/app-unhandled-exception.js +++ b/test/crashtracker/app-unhandled-exception.js @@ -10,8 +10,9 @@ function myFaultyFunction () { } crashtracker.beginProfilerSerializing() -try { - myFaultyFunction() -} catch (e) { + +process.on('uncaughtException', (e) => { crashtracker.reportUnhandledException(e) -} +}) + +myFaultyFunction() From 8263d0d70390ebd668199625a919db8a3d200757 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Thu, 26 Feb 2026 12:32:44 +0100 Subject: [PATCH 3/7] uncaughtException -> uncaughtExceptionMonitor --- test/crashtracker/app-unhandled-exception.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/crashtracker/app-unhandled-exception.js b/test/crashtracker/app-unhandled-exception.js index 2f371ee..78fbab4 100644 --- a/test/crashtracker/app-unhandled-exception.js +++ b/test/crashtracker/app-unhandled-exception.js @@ -11,7 +11,7 @@ function myFaultyFunction () { crashtracker.beginProfilerSerializing() -process.on('uncaughtException', (e) => { +process.on('uncaughtExceptionMonitor', (e) => { crashtracker.reportUnhandledException(e) }) From c17bd04f478ff03c7f6f24dc7d5ea3931c594418 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Fri, 27 Feb 2026 13:40:58 +0100 Subject: [PATCH 4/7] Handle unhandled promise rejections and add test scenarios --- crates/crashtracker/src/lib.rs | 205 +-------------- .../crashtracker/src/unhandled_exception.rs | 235 ++++++++++++++++++ .../app-uncaught-exception-non-error.js | 14 ++ ...exception.js => app-uncaught-exception.js} | 10 +- .../app-unhandled-rejection-non-error.js | 14 ++ test/crashtracker/app-unhandled-rejection.js | 18 ++ test/crashtracker/index.js | 137 +++++----- 7 files changed, 355 insertions(+), 278 deletions(-) create mode 100644 crates/crashtracker/src/unhandled_exception.rs create mode 100644 test/crashtracker/app-uncaught-exception-non-error.js rename test/crashtracker/{app-unhandled-exception.js => app-uncaught-exception.js} (89%) create mode 100644 test/crashtracker/app-unhandled-rejection-non-error.js create mode 100644 test/crashtracker/app-unhandled-rejection.js diff --git a/crates/crashtracker/src/lib.rs b/crates/crashtracker/src/lib.rs index b02fddb..28366af 100644 --- a/crates/crashtracker/src/lib.rs +++ b/crates/crashtracker/src/lib.rs @@ -1,6 +1,8 @@ -use napi::{Env, JsObject, JsUnknown}; +use napi::{Env, JsUnknown}; use napi_derive::napi; +mod unhandled_exception; + /// Ensures that if signals is empty, default signals are applied. /// This is necessary because NAPI deserialization bypasses the /// CrashtrackerConfiguration::new() constructor where the default @@ -77,204 +79,3 @@ pub fn end_profiler_serializing(_env: Env) -> napi::Result<()> { Ok(()) } - -fn get_optional_string_property(obj: &JsObject, key: &str) -> napi::Result> { - match obj.get_named_property::(key) { - Ok(val) => { - use napi::ValueType; - if val.get_type()? == ValueType::String { - let s: String = val.coerce_to_string()?.into_utf8()?.as_str()?.to_owned(); - if s.is_empty() { - Ok(None) - } else { - Ok(Some(s)) - } - } else { - Ok(None) - } - } - Err(_) => Ok(None), - } -} - -fn parse_v8_stack(stack: &str) -> libdd_crashtracker::StackTrace { - let mut frames = Vec::new(); - - for line in stack.lines().skip(1) { - let line = line.trim(); - let line = match line.strip_prefix("at ") { - Some(rest) => rest, - None => continue, - }; - - let mut frame = libdd_crashtracker::StackFrame::new(); - - // Formats: - // "functionName (file:line:col)" - // "functionName (file:line)" - // "file:line:col" - // "file:line" - if let Some(paren_start) = line.rfind('(') { - let func_name = line[..paren_start].trim(); - if !func_name.is_empty() { - frame.function = Some(func_name.to_string()); - } - let location = line[paren_start + 1..].trim_end_matches(')'); - parse_location(location, &mut frame); - } else { - parse_location(line, &mut frame); - } - - frames.push(frame); - } - - libdd_crashtracker::StackTrace::from_frames(frames, false) -} - -fn parse_location(location: &str, frame: &mut libdd_crashtracker::StackFrame) { - // location is "file:line:col" or "file:line" or just "native" etc. - // The file portion may contain ":" ("node:internal/...") - // so we split from the right. - let parts: Vec<&str> = location.rsplitn(3, ':').collect(); - match parts.len() { - 3 => { - // col, line, file - frame.column = parts[0].parse().ok(); - frame.line = parts[1].parse().ok(); - frame.file = Some(parts[2].to_string()); - } - 2 => { - if let Ok(line_num) = parts[0].parse::() { - frame.line = Some(line_num); - frame.file = Some(parts[1].to_string()); - } else { - frame.file = Some(location.to_string()); - } - } - _ => { - frame.file = Some(location.to_string()); - } - } -} - -#[napi] -pub fn report_unhandled_exception(_env: Env, error: JsObject) -> napi::Result<()> { - let exception_type = get_optional_string_property(&error, "name")?; - let exception_message = get_optional_string_property(&error, "message")?; - let stack_string = get_optional_string_property(&error, "stack")?; - - let stacktrace = match &stack_string { - Some(s) => parse_v8_stack(s), - None => libdd_crashtracker::StackTrace::empty(), - }; - - libdd_crashtracker::report_unhandled_exception( - exception_type.as_deref(), - exception_message.as_deref(), - stacktrace, - ) - .unwrap(); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_v8_stack_typical_error() { - let stack = "\ -TypeError: Cannot read properties of undefined (reading 'foo') - at Object.method (/app/src/index.js:10:15) - at Module._compile (node:internal/modules/cjs/loader:1234:14) - at /app/src/helper.js:5:3"; - - let trace = parse_v8_stack(stack); - assert_eq!(trace.frames.len(), 3); - assert!(!trace.incomplete); - - assert_eq!(trace.frames[0].function.as_deref(), Some("Object.method")); - assert_eq!(trace.frames[0].file.as_deref(), Some("/app/src/index.js")); - assert_eq!(trace.frames[0].line, Some(10)); - assert_eq!(trace.frames[0].column, Some(15)); - - assert_eq!(trace.frames[1].function.as_deref(), Some("Module._compile")); - assert_eq!( - trace.frames[1].file.as_deref(), - Some("node:internal/modules/cjs/loader") - ); - assert_eq!(trace.frames[1].line, Some(1234)); - assert_eq!(trace.frames[1].column, Some(14)); - - assert_eq!(trace.frames[2].function, None); - assert_eq!(trace.frames[2].file.as_deref(), Some("/app/src/helper.js")); - assert_eq!(trace.frames[2].line, Some(5)); - assert_eq!(trace.frames[2].column, Some(3)); - } - - #[test] - fn test_parse_v8_stack_anonymous_and_native() { - let stack = "\ -Error: boom - at :1:1 - at native"; - - let trace = parse_v8_stack(stack); - assert_eq!(trace.frames.len(), 2); - - assert_eq!(trace.frames[0].file.as_deref(), Some("")); - assert_eq!(trace.frames[0].line, Some(1)); - assert_eq!(trace.frames[0].column, Some(1)); - - assert_eq!(trace.frames[1].file.as_deref(), Some("native")); - assert_eq!(trace.frames[1].line, None); - } - - #[test] - fn test_parse_v8_stack_empty() { - let stack = "Error: something"; - let trace = parse_v8_stack(stack); - assert_eq!(trace.frames.len(), 0); - assert!(!trace.incomplete); - } - - #[test] - fn test_parse_location_file_line_col() { - let mut frame = libdd_crashtracker::StackFrame::new(); - parse_location("/app/index.js:42:7", &mut frame); - assert_eq!(frame.file.as_deref(), Some("/app/index.js")); - assert_eq!(frame.line, Some(42)); - assert_eq!(frame.column, Some(7)); - } - - #[test] - fn test_parse_location_node_internal() { - let mut frame = libdd_crashtracker::StackFrame::new(); - parse_location("node:internal/modules/cjs/loader:1234:14", &mut frame); - assert_eq!( - frame.file.as_deref(), - Some("node:internal/modules/cjs/loader") - ); - assert_eq!(frame.line, Some(1234)); - assert_eq!(frame.column, Some(14)); - } - - #[test] - fn test_parse_location_no_column() { - let mut frame = libdd_crashtracker::StackFrame::new(); - parse_location("/app/index.js:42", &mut frame); - assert_eq!(frame.file.as_deref(), Some("/app/index.js")); - assert_eq!(frame.line, Some(42)); - assert_eq!(frame.column, None); - } - - #[test] - fn test_parse_location_bare_path() { - let mut frame = libdd_crashtracker::StackFrame::new(); - parse_location("native", &mut frame); - assert_eq!(frame.file.as_deref(), Some("native")); - assert_eq!(frame.line, None); - assert_eq!(frame.column, None); - } -} diff --git a/crates/crashtracker/src/unhandled_exception.rs b/crates/crashtracker/src/unhandled_exception.rs new file mode 100644 index 0000000..bd9c62f --- /dev/null +++ b/crates/crashtracker/src/unhandled_exception.rs @@ -0,0 +1,235 @@ +use napi::{Env, JsFunction, JsObject, JsUnknown}; +use napi_derive::napi; + +fn get_optional_string_property(obj: &JsObject, key: &str) -> napi::Result> { + match obj.get_named_property::(key) { + Ok(val) => { + use napi::ValueType; + if val.get_type()? == ValueType::String { + let s: String = val.coerce_to_string()?.into_utf8()?.as_str()?.to_owned(); + if s.is_empty() { + Ok(None) + } else { + Ok(Some(s)) + } + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } +} + +fn parse_v8_stack(stack: &str) -> libdd_crashtracker::StackTrace { + let mut frames = Vec::new(); + + for line in stack.lines().skip(1) { + let line = line.trim(); + let line = match line.strip_prefix("at ") { + Some(rest) => rest, + None => continue, + }; + + let mut frame = libdd_crashtracker::StackFrame::new(); + + // Formats: + // "functionName (file:line:col)" + // "functionName (file:line)" + // "file:line:col" + // "file:line" + if let Some(paren_start) = line.rfind('(') { + let func_name = line[..paren_start].trim(); + if !func_name.is_empty() { + frame.function = Some(func_name.to_string()); + } + let location = line[paren_start + 1..].trim_end_matches(')'); + parse_location(location, &mut frame); + } else { + parse_location(line, &mut frame); + } + + frames.push(frame); + } + + libdd_crashtracker::StackTrace::from_frames(frames, false) +} + +fn parse_location(location: &str, frame: &mut libdd_crashtracker::StackFrame) { + // location is "file:line:col" or "file:line" or just "native" etc. + // The file portion may contain ":" ("node:internal/...") + // so we split from the right. + let parts: Vec<&str> = location.rsplitn(3, ':').collect(); + match parts.len() { + 3 => { + // col, line, file + frame.column = parts[0].parse().ok(); + frame.line = parts[1].parse().ok(); + frame.file = Some(parts[2].to_string()); + } + 2 => { + if let Ok(line_num) = parts[0].parse::() { + frame.line = Some(line_num); + frame.file = Some(parts[1].to_string()); + } else { + frame.file = Some(location.to_string()); + } + } + _ => { + frame.file = Some(location.to_string()); + } + } +} + +fn is_error_instance(env: &Env, value: &JsUnknown) -> napi::Result { + let global = env.get_global()?; + let error_ctor: JsFunction = global.get_named_property("Error")?; + value.instanceof(error_ctor) +} + +fn stringify_js_value(value: JsUnknown) -> napi::Result { + let s = value.coerce_to_string()?.into_utf8()?; + Ok(s.as_str()?.to_owned()) +} + +fn report_unhandled(env: &Env, error: JsUnknown, fallback_type: &str) -> napi::Result<()> { + let is_error = is_error_instance(env, &error)?; + let (exception_type, exception_message, stacktrace) = if is_error { + let error_obj: JsObject = error.coerce_to_object()?; + let name = get_optional_string_property(&error_obj, "name")?; + let message = get_optional_string_property(&error_obj, "message")?; + let stack_string = get_optional_string_property(&error_obj, "stack")?; + let stacktrace = match &stack_string { + Some(s) => parse_v8_stack(s), + None => libdd_crashtracker::StackTrace::new_incomplete(), + }; + (name, message, stacktrace) + } else { + let message = stringify_js_value(error).ok(); + ( + Some(fallback_type.to_string()), + message, + // libdatadog defines a missing stacktrace as incomplete + libdd_crashtracker::StackTrace::new_incomplete(), + ) + }; + + libdd_crashtracker::report_unhandled_exception( + exception_type.as_deref(), + exception_message.as_deref(), + stacktrace, + ) + .unwrap(); + + Ok(()) +} + +#[napi] +pub fn report_uncaught_exception(env: Env, error: JsUnknown) -> napi::Result<()> { + report_unhandled(&env, error, "uncaughtException") +} + +#[napi] +pub fn report_unhandled_rejection(env: Env, error: JsUnknown) -> napi::Result<()> { + report_unhandled(&env, error, "unhandledRejection") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_v8_stack_typical_error() { + let stack = "\ +TypeError: Cannot read properties of undefined (reading 'foo') + at Object.method (/app/src/index.js:10:15) + at Module._compile (node:internal/modules/cjs/loader:1234:14) + at /app/src/helper.js:5:3"; + + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 3); + assert!(!trace.incomplete); + + assert_eq!(trace.frames[0].function.as_deref(), Some("Object.method")); + assert_eq!(trace.frames[0].file.as_deref(), Some("/app/src/index.js")); + assert_eq!(trace.frames[0].line, Some(10)); + assert_eq!(trace.frames[0].column, Some(15)); + + assert_eq!(trace.frames[1].function.as_deref(), Some("Module._compile")); + assert_eq!( + trace.frames[1].file.as_deref(), + Some("node:internal/modules/cjs/loader") + ); + assert_eq!(trace.frames[1].line, Some(1234)); + assert_eq!(trace.frames[1].column, Some(14)); + + assert_eq!(trace.frames[2].function, None); + assert_eq!(trace.frames[2].file.as_deref(), Some("/app/src/helper.js")); + assert_eq!(trace.frames[2].line, Some(5)); + assert_eq!(trace.frames[2].column, Some(3)); + } + + #[test] + fn test_parse_v8_stack_anonymous_and_native() { + let stack = "\ +Error: boom + at :1:1 + at native"; + + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 2); + + assert_eq!(trace.frames[0].file.as_deref(), Some("")); + assert_eq!(trace.frames[0].line, Some(1)); + assert_eq!(trace.frames[0].column, Some(1)); + + assert_eq!(trace.frames[1].file.as_deref(), Some("native")); + assert_eq!(trace.frames[1].line, None); + } + + #[test] + fn test_parse_v8_stack_empty() { + let stack = "Error: something"; + let trace = parse_v8_stack(stack); + assert_eq!(trace.frames.len(), 0); + assert!(!trace.incomplete); + } + + #[test] + fn test_parse_location_file_line_col() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("/app/index.js:42:7", &mut frame); + assert_eq!(frame.file.as_deref(), Some("/app/index.js")); + assert_eq!(frame.line, Some(42)); + assert_eq!(frame.column, Some(7)); + } + + #[test] + fn test_parse_location_node_internal() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("node:internal/modules/cjs/loader:1234:14", &mut frame); + assert_eq!( + frame.file.as_deref(), + Some("node:internal/modules/cjs/loader") + ); + assert_eq!(frame.line, Some(1234)); + assert_eq!(frame.column, Some(14)); + } + + #[test] + fn test_parse_location_no_column() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("/app/index.js:42", &mut frame); + assert_eq!(frame.file.as_deref(), Some("/app/index.js")); + assert_eq!(frame.line, Some(42)); + assert_eq!(frame.column, None); + } + + #[test] + fn test_parse_location_bare_path() { + let mut frame = libdd_crashtracker::StackFrame::new(); + parse_location("native", &mut frame); + assert_eq!(frame.file.as_deref(), Some("native")); + assert_eq!(frame.line, None); + assert_eq!(frame.column, None); + } +} diff --git a/test/crashtracker/app-uncaught-exception-non-error.js b/test/crashtracker/app-uncaught-exception-non-error.js new file mode 100644 index 0000000..386931c --- /dev/null +++ b/test/crashtracker/app-uncaught-exception-non-error.js @@ -0,0 +1,14 @@ +'use strict' + +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') +const { initTestCrashtracker } = require('./test_utils') + +initTestCrashtracker() +crashtracker.beginProfilerSerializing() + +process.on('uncaughtExceptionMonitor', (e) => { + crashtracker.reportUncaughtException(e) +}) + +throw 'a plain string error' diff --git a/test/crashtracker/app-unhandled-exception.js b/test/crashtracker/app-uncaught-exception.js similarity index 89% rename from test/crashtracker/app-unhandled-exception.js rename to test/crashtracker/app-uncaught-exception.js index 78fbab4..6131280 100644 --- a/test/crashtracker/app-unhandled-exception.js +++ b/test/crashtracker/app-uncaught-exception.js @@ -5,14 +5,14 @@ const crashtracker = libdatadog.load('crashtracker') const { initTestCrashtracker } = require('./test_utils') initTestCrashtracker() -function myFaultyFunction () { - throw new TypeError('something went wrong') -} - crashtracker.beginProfilerSerializing() process.on('uncaughtExceptionMonitor', (e) => { - crashtracker.reportUnhandledException(e) + crashtracker.reportUncaughtException(e) }) +function myFaultyFunction () { + throw new TypeError('something went wrong') +} + myFaultyFunction() diff --git a/test/crashtracker/app-unhandled-rejection-non-error.js b/test/crashtracker/app-unhandled-rejection-non-error.js new file mode 100644 index 0000000..a9f71cf --- /dev/null +++ b/test/crashtracker/app-unhandled-rejection-non-error.js @@ -0,0 +1,14 @@ +'use strict' + +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') +const { initTestCrashtracker } = require('./test_utils') + +initTestCrashtracker() +crashtracker.beginProfilerSerializing() + +process.on('unhandledRejection', (reason) => { + crashtracker.reportUnhandledRejection(reason) +}) + +Promise.reject('a plain string rejection') diff --git a/test/crashtracker/app-unhandled-rejection.js b/test/crashtracker/app-unhandled-rejection.js new file mode 100644 index 0000000..6dc864e --- /dev/null +++ b/test/crashtracker/app-unhandled-rejection.js @@ -0,0 +1,18 @@ +'use strict' + +const libdatadog = require('../..') +const crashtracker = libdatadog.load('crashtracker') +const { initTestCrashtracker } = require('./test_utils') + +initTestCrashtracker() +crashtracker.beginProfilerSerializing() + +process.on('unhandledRejection', (reason) => { + crashtracker.reportUnhandledRejection(reason) +}) + +async function myAsyncFaultyFunction () { + throw new Error('async went wrong') +} + +myAsyncFaultyFunction() diff --git a/test/crashtracker/index.js b/test/crashtracker/index.js index 683bd59..81de7ba 100644 --- a/test/crashtracker/index.js +++ b/test/crashtracker/index.js @@ -47,97 +47,92 @@ app.post('/telemetry/proxy/api/v2/apmtelemetry', (req, res) => { } }) -function runSegfaultTest (PORT) { - return new Promise((resolve, reject) => { - currentTest = (logPayload, tags) => { - currentTest = null - const stackTrace = JSON.parse(logPayload.message).error.stack.frames - - const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) - - if (existsSync('/etc/alpine-release')) { - // TODO: Remove this when supported. - console.log('Received crash report. Skipping stack trace test since it is currently unsupported for Alpine.') - } else if (boomFrame) { - console.log('Stack frame for crashing function successfully received by the mock agent.') - } else { - return reject(new Error('Could not find a stack frame for the crashing function.')) - } +let PORT - if (tags.includes('profiler_serializing:1')) { - console.log('Stack trace was marked as happened during profile serialization.') - } else { - return reject(new Error('Stack trace was not marked as happening during profile serialization.')) - } - - resolve() - } - - exec('node app-seg-fault', { +function runApp (script, { expectSignal } = {}) { + return new Promise((resolve) => { + exec(`node ${script}`, { ...opts, env: { ...process.env, PORT } }, e => { - if (e && e.signal !== 'SIGSEGV' && e.code !== 139 && e.status !== 139) { - reject(e) + if (e) { + if (expectSignal && (e.signal === expectSignal || e.code === 139 || e.status === 139)) { + return + } } }) + + currentTest = (logPayload, tags) => { + currentTest = null + resolve({ logPayload, tags }) + } }) } -function runUnhandledExceptionTest (PORT) { - return new Promise((resolve, reject) => { - rmSync(path.join(cwd, 'stdout.log'), { force: true }) - rmSync(path.join(cwd, 'stderr.log'), { force: true }) +function assert (condition, label, message) { + if (!condition) { + throw new Error(`[${label}] ${message}`) + } + console.log(`[${label}] ${message}`) +} - currentTest = (logPayload) => { - currentTest = null - const crashReport = JSON.parse(logPayload.message) - const stackTrace = crashReport.error.stack.frames - const errorMessage = crashReport.error.message - const errorKind = crashReport.error.kind - - if (errorKind === 'UnhandledException') { - console.log('Error kind correctly reported as UnhandledException.') - } else { - return reject(new Error(`Expected error kind "UnhandledException" but got "${errorKind}".`)) - } +async function testSegfault () { + const { logPayload, tags } = await runApp('app-seg-fault', { expectSignal: 'SIGSEGV' }) + const stackTrace = JSON.parse(logPayload.message).error.stack.frames + const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) - if (errorMessage.includes('TypeError') && errorMessage.includes('something went wrong')) { - console.log('Exception type and message correctly captured.') - } else { - return reject(new Error(`Error message did not contain expected content: "${errorMessage}".`)) - } + if (existsSync('/etc/alpine-release')) { + console.log('[segfault] Received crash report. Skipping stack trace test since it is currently unsupported for Alpine.') + } else { + assert(boomFrame, 'segfault', 'Stack frame for crashing function successfully received.') + } - const faultyFrame = stackTrace.find(frame => - frame.function && frame.function.includes('myFaultyFunction') - ) + assert(tags.includes('profiler_serializing:1'), 'segfault', 'Stack trace was marked as happened during profile serialization.') +} - if (faultyFrame) { - console.log('Stack frame for myFaultyFunction successfully received by the mock agent.') - } else { - return reject(new Error('Could not find a stack frame for myFaultyFunction.')) - } +async function testUnhandledError (label, script, { expectedType, expectedMessage, expectedFrame }) { + const { logPayload } = await runApp(script) + const crashReport = JSON.parse(logPayload.message) - resolve() - } + assert(crashReport.error.message.includes(expectedType), label, `Exception type "${expectedType}" captured in message.`) + assert(crashReport.error.message.includes(expectedMessage), label, `Exception message "${expectedMessage}" captured.`) - exec('node app-unhandled-exception', { - ...opts, - env: { ...process.env, PORT } - }, e => { - if (e) { - // tolerate non-zero exit since reportUnhandledException disables the crash handler - } - }) - }) + const frame = crashReport.error.stack.frames.find(f => f.function && f.function.includes(expectedFrame)) + assert(frame, label, `Stack frame for ${expectedFrame} successfully received.`) +} + +async function testUnhandledNonError (label, script, { expectedFallbackType, expectedValue }) { + const { logPayload } = await runApp(script) + const crashReport = JSON.parse(logPayload.message) + + assert(crashReport.error.message.includes(expectedFallbackType), label, `Fallback type "${expectedFallbackType}" captured in message.`) + assert(crashReport.error.message.includes(expectedValue), label, `Stringified value "${expectedValue}" captured in message.`) + assert(crashReport.error.stack.frames.length === 0, label, 'Empty stack trace correctly reported.') } const server = app.listen(async () => { - const PORT = server.address().port + PORT = server.address().port try { - await runSegfaultTest(PORT) - await runUnhandledExceptionTest(PORT) + await testSegfault() + await testUnhandledError('uncaught-exception', 'app-uncaught-exception', { + expectedType: 'TypeError', + expectedMessage: 'something went wrong', + expectedFrame: 'myFaultyFunction' + }) + await testUnhandledNonError('uncaught-exception-non-error', 'app-uncaught-exception-non-error', { + expectedFallbackType: 'uncaughtException', + expectedValue: 'a plain string error' + }) + await testUnhandledError('unhandled-rejection', 'app-unhandled-rejection', { + expectedType: 'Error', + expectedMessage: 'async went wrong', + expectedFrame: 'myAsyncFaultyFunction' + }) + await testUnhandledNonError('unhandled-rejection-non-error', 'app-unhandled-rejection-non-error', { + expectedFallbackType: 'unhandledRejection', + expectedValue: 'a plain string rejection' + }) } catch (e) { clearTimeout(timeout) server.close(() => { throw e }) From 4f77fefdecd6fdaec9a545d3b62d1b523e1c06c7 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Fri, 27 Feb 2026 14:15:43 +0100 Subject: [PATCH 5/7] We only need uncaughtExceptionMonitor --- crates/crashtracker/src/unhandled_exception.rs | 15 ++++++++------- .../app-uncaught-exception-non-error.js | 4 ++-- test/crashtracker/app-uncaught-exception.js | 4 ++-- .../app-unhandled-rejection-non-error.js | 4 ++-- test/crashtracker/app-unhandled-rejection.js | 4 ++-- test/crashtracker/index.js | 17 +++++++++++------ 6 files changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/crashtracker/src/unhandled_exception.rs b/crates/crashtracker/src/unhandled_exception.rs index bd9c62f..64dd8fc 100644 --- a/crates/crashtracker/src/unhandled_exception.rs +++ b/crates/crashtracker/src/unhandled_exception.rs @@ -104,6 +104,8 @@ fn report_unhandled(env: &Env, error: JsUnknown, fallback_type: &str) -> napi::R }; (name, message, stacktrace) } else { + // This only fires for synchronous `throw `; node already + // wraps non-Error unhandled rejections in an Error object let message = stringify_js_value(error).ok(); ( Some(fallback_type.to_string()), @@ -124,13 +126,12 @@ fn report_unhandled(env: &Env, error: JsUnknown, fallback_type: &str) -> napi::R } #[napi] -pub fn report_uncaught_exception(env: Env, error: JsUnknown) -> napi::Result<()> { - report_unhandled(&env, error, "uncaughtException") -} - -#[napi] -pub fn report_unhandled_rejection(env: Env, error: JsUnknown) -> napi::Result<()> { - report_unhandled(&env, error, "unhandledRejection") +pub fn report_uncaught_exception_monitor( + env: Env, + error: JsUnknown, + origin: String, +) -> napi::Result<()> { + report_unhandled(&env, error, &origin) } #[cfg(test)] diff --git a/test/crashtracker/app-uncaught-exception-non-error.js b/test/crashtracker/app-uncaught-exception-non-error.js index 386931c..418fa4a 100644 --- a/test/crashtracker/app-uncaught-exception-non-error.js +++ b/test/crashtracker/app-uncaught-exception-non-error.js @@ -7,8 +7,8 @@ const { initTestCrashtracker } = require('./test_utils') initTestCrashtracker() crashtracker.beginProfilerSerializing() -process.on('uncaughtExceptionMonitor', (e) => { - crashtracker.reportUncaughtException(e) +process.on('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) }) throw 'a plain string error' diff --git a/test/crashtracker/app-uncaught-exception.js b/test/crashtracker/app-uncaught-exception.js index 6131280..bc94be3 100644 --- a/test/crashtracker/app-uncaught-exception.js +++ b/test/crashtracker/app-uncaught-exception.js @@ -7,8 +7,8 @@ const { initTestCrashtracker } = require('./test_utils') initTestCrashtracker() crashtracker.beginProfilerSerializing() -process.on('uncaughtExceptionMonitor', (e) => { - crashtracker.reportUncaughtException(e) +process.on('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) }) function myFaultyFunction () { diff --git a/test/crashtracker/app-unhandled-rejection-non-error.js b/test/crashtracker/app-unhandled-rejection-non-error.js index a9f71cf..fc45909 100644 --- a/test/crashtracker/app-unhandled-rejection-non-error.js +++ b/test/crashtracker/app-unhandled-rejection-non-error.js @@ -7,8 +7,8 @@ const { initTestCrashtracker } = require('./test_utils') initTestCrashtracker() crashtracker.beginProfilerSerializing() -process.on('unhandledRejection', (reason) => { - crashtracker.reportUnhandledRejection(reason) +process.on('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) }) Promise.reject('a plain string rejection') diff --git a/test/crashtracker/app-unhandled-rejection.js b/test/crashtracker/app-unhandled-rejection.js index 6dc864e..963d60a 100644 --- a/test/crashtracker/app-unhandled-rejection.js +++ b/test/crashtracker/app-unhandled-rejection.js @@ -7,8 +7,8 @@ const { initTestCrashtracker } = require('./test_utils') initTestCrashtracker() crashtracker.beginProfilerSerializing() -process.on('unhandledRejection', (reason) => { - crashtracker.reportUnhandledRejection(reason) +process.on('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) }) async function myAsyncFaultyFunction () { diff --git a/test/crashtracker/index.js b/test/crashtracker/index.js index 81de7ba..e89a24a 100644 --- a/test/crashtracker/index.js +++ b/test/crashtracker/index.js @@ -96,9 +96,10 @@ async function testUnhandledError (label, script, { expectedType, expectedMessag assert(crashReport.error.message.includes(expectedType), label, `Exception type "${expectedType}" captured in message.`) assert(crashReport.error.message.includes(expectedMessage), label, `Exception message "${expectedMessage}" captured.`) - - const frame = crashReport.error.stack.frames.find(f => f.function && f.function.includes(expectedFrame)) - assert(frame, label, `Stack frame for ${expectedFrame} successfully received.`) + if (expectedFrame) { + const frame = crashReport.error.stack.frames.find(f => f.function && f.function.includes(expectedFrame)) + assert(frame, label, `Stack frame for ${expectedFrame} successfully received.`) + } } async function testUnhandledNonError (label, script, { expectedFallbackType, expectedValue }) { @@ -129,9 +130,13 @@ const server = app.listen(async () => { expectedMessage: 'async went wrong', expectedFrame: 'myAsyncFaultyFunction' }) - await testUnhandledNonError('unhandled-rejection-non-error', 'app-unhandled-rejection-non-error', { - expectedFallbackType: 'unhandledRejection', - expectedValue: 'a plain string rejection' + // Node wraps non-Error rejections in an Error with name 'UnhandledPromiseRejection' + // before passing to uncaughtExceptionMonitor, so this hits the Error path. + // However, this test case rejects with a plain string, so the wrapped Error object has useless + // stack trace + await testUnhandledError('unhandled-rejection-non-error', 'app-unhandled-rejection-non-error', { + expectedType: 'UnhandledPromiseRejection', + expectedMessage: 'a plain string rejection' }) } catch (e) { clearTimeout(timeout) From f00ea2d9ccf425d85f7fb790ae3d08184a664897 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Sun, 1 Mar 2026 01:53:41 -0500 Subject: [PATCH 6/7] Clean test cases --- test/crashtracker/index.js | 69 +++++++++++++----------------- test/crashtracker/test_utils.js | 76 +++++++++++++++++---------------- 2 files changed, 69 insertions(+), 76 deletions(-) diff --git a/test/crashtracker/index.js b/test/crashtracker/index.js index e89a24a..faf7869 100644 --- a/test/crashtracker/index.js +++ b/test/crashtracker/index.js @@ -42,24 +42,20 @@ app.post('/telemetry/proxy/api/v2/apmtelemetry', (req, res) => { return } - if (currentTest) { - currentTest(logPayload, tags) + if (!currentTest) { + throw new Error('Received unexpected crash report with no active test.') } + + currentTest(logPayload, tags) }) let PORT -function runApp (script, { expectSignal } = {}) { +function runApp (script) { return new Promise((resolve) => { exec(`node ${script}`, { ...opts, env: { ...process.env, PORT } - }, e => { - if (e) { - if (expectSignal && (e.signal === expectSignal || e.code === 139 || e.status === 139)) { - return - } - } }) currentTest = (logPayload, tags) => { @@ -77,7 +73,7 @@ function assert (condition, label, message) { } async function testSegfault () { - const { logPayload, tags } = await runApp('app-seg-fault', { expectSignal: 'SIGSEGV' }) + const { logPayload, tags } = await runApp('app-seg-fault') const stackTrace = JSON.parse(logPayload.message).error.stack.frames const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) @@ -114,35 +110,30 @@ async function testUnhandledNonError (label, script, { expectedFallbackType, exp const server = app.listen(async () => { PORT = server.address().port - try { - await testSegfault() - await testUnhandledError('uncaught-exception', 'app-uncaught-exception', { - expectedType: 'TypeError', - expectedMessage: 'something went wrong', - expectedFrame: 'myFaultyFunction' - }) - await testUnhandledNonError('uncaught-exception-non-error', 'app-uncaught-exception-non-error', { - expectedFallbackType: 'uncaughtException', - expectedValue: 'a plain string error' - }) - await testUnhandledError('unhandled-rejection', 'app-unhandled-rejection', { - expectedType: 'Error', - expectedMessage: 'async went wrong', - expectedFrame: 'myAsyncFaultyFunction' - }) - // Node wraps non-Error rejections in an Error with name 'UnhandledPromiseRejection' - // before passing to uncaughtExceptionMonitor, so this hits the Error path. - // However, this test case rejects with a plain string, so the wrapped Error object has useless - // stack trace - await testUnhandledError('unhandled-rejection-non-error', 'app-unhandled-rejection-non-error', { - expectedType: 'UnhandledPromiseRejection', - expectedMessage: 'a plain string rejection' - }) - } catch (e) { - clearTimeout(timeout) - server.close(() => { throw e }) - return - } + + await testSegfault() + await testUnhandledError('uncaught-exception', 'app-uncaught-exception', { + expectedType: 'TypeError', + expectedMessage: 'something went wrong', + expectedFrame: 'myFaultyFunction' + }) + await testUnhandledNonError('uncaught-exception-non-error', 'app-uncaught-exception-non-error', { + expectedFallbackType: 'uncaughtException', + expectedValue: 'a plain string error' + }) + await testUnhandledError('unhandled-rejection', 'app-unhandled-rejection', { + expectedType: 'Error', + expectedMessage: 'async went wrong', + expectedFrame: 'myAsyncFaultyFunction' + }) + // Node wraps non-Error rejections in an Error with name 'UnhandledPromiseRejection' + // before passing to uncaughtExceptionMonitor, so this hits the Error path. + // However, this test case rejects with a plain string, so the wrapped Error object has useless + // stack trace + await testUnhandledError('unhandled-rejection-non-error', 'app-unhandled-rejection-non-error', { + expectedType: 'UnhandledPromiseRejection', + expectedMessage: 'a plain string rejection' + }) clearTimeout(timeout) server.close() diff --git a/test/crashtracker/test_utils.js b/test/crashtracker/test_utils.js index 5342db9..8062d9c 100644 --- a/test/crashtracker/test_utils.js +++ b/test/crashtracker/test_utils.js @@ -1,43 +1,45 @@ +'use strict' + const libdatadog = require('../..') const crashtracker = libdatadog.load('crashtracker') -function initTestCrashtracker() { - crashtracker.init({ - additional_files: [], - create_alt_stack: true, - use_alt_stack: true, - endpoint: { - url: { - scheme: 'http', - authority: `127.0.0.1:${process.env.PORT || 8126}`, - path_and_query: '' - }, - timeout_ms: 3000 - }, - timeout: { secs: 3, nanos: 0 }, - resolve_frames: 'EnabledWithInprocessSymbols', - wait_for_receiver: true, - demangle_names: false, - signals: [] - }, { - args: [], - env: [], - path_to_receiver_binary: libdatadog.find('crashtracker-receiver', true), - stderr_filename: 'stderr.log', - stdout_filename: 'stdout.log', - }, { - library_name: 'dd-trace-js', - library_version: '6.0.0-pre', - family: 'javascript', - tags: [ - 'language:javascript', - 'runtime:nodejs', - 'runtime-id:8a8fef6433a849b3bc3171198831d102', - 'library_version:6.0.0-pre', - 'is_crash:true', - 'severity:crash' - ] - }) +function initTestCrashtracker () { + crashtracker.init({ + additional_files: [], + create_alt_stack: true, + use_alt_stack: true, + endpoint: { + url: { + scheme: 'http', + authority: `127.0.0.1:${process.env.PORT || 8126}`, + path_and_query: '' + }, + timeout_ms: 3000 + }, + timeout: { secs: 3, nanos: 0 }, + resolve_frames: 'EnabledWithInprocessSymbols', + wait_for_receiver: true, + demangle_names: false, + signals: [] + }, { + args: [], + env: [], + path_to_receiver_binary: libdatadog.find('crashtracker-receiver', true), + stderr_filename: 'stderr.log', + stdout_filename: 'stdout.log', + }, { + library_name: 'dd-trace-js', + library_version: '6.0.0-pre', + family: 'javascript', + tags: [ + 'language:javascript', + 'runtime:nodejs', + 'runtime-id:8a8fef6433a849b3bc3171198831d102', + 'library_version:6.0.0-pre', + 'is_crash:true', + 'severity:crash' + ] + }) } module.exports = { initTestCrashtracker } From c167e8eed3e580fce8524e06c03bf39500cffb71 Mon Sep 17 00:00:00 2001 From: Gyuheon Oh Date: Sun, 1 Mar 2026 18:41:53 -0500 Subject: [PATCH 7/7] Assert library --- test/crashtracker/index.js | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/test/crashtracker/index.js b/test/crashtracker/index.js index faf7869..1118fdb 100644 --- a/test/crashtracker/index.js +++ b/test/crashtracker/index.js @@ -12,6 +12,7 @@ execSync('yarn install', opts) const express = require('express') const bodyParser = require('body-parser') +const assert = require('assert') const { existsSync, rmSync } = require('fs') const path = require('path') @@ -65,13 +66,6 @@ function runApp (script) { }) } -function assert (condition, label, message) { - if (!condition) { - throw new Error(`[${label}] ${message}`) - } - console.log(`[${label}] ${message}`) -} - async function testSegfault () { const { logPayload, tags } = await runApp('app-seg-fault') const stackTrace = JSON.parse(logPayload.message).error.stack.frames @@ -80,21 +74,21 @@ async function testSegfault () { if (existsSync('/etc/alpine-release')) { console.log('[segfault] Received crash report. Skipping stack trace test since it is currently unsupported for Alpine.') } else { - assert(boomFrame, 'segfault', 'Stack frame for crashing function successfully received.') + assert(boomFrame, '[segfault] Expected stack frame for crashing function not found.') } - assert(tags.includes('profiler_serializing:1'), 'segfault', 'Stack trace was marked as happened during profile serialization.') + assert(tags.includes('profiler_serializing:1'), '[segfault] Expected profiler_serializing:1 tag not found.') } async function testUnhandledError (label, script, { expectedType, expectedMessage, expectedFrame }) { const { logPayload } = await runApp(script) const crashReport = JSON.parse(logPayload.message) - assert(crashReport.error.message.includes(expectedType), label, `Exception type "${expectedType}" captured in message.`) - assert(crashReport.error.message.includes(expectedMessage), label, `Exception message "${expectedMessage}" captured.`) + assert(crashReport.error.message.includes(expectedType), `[${label}] Expected exception type "${expectedType}" not found in message.`) + assert(crashReport.error.message.includes(expectedMessage), `[${label}] Expected exception message "${expectedMessage}" not found.`) if (expectedFrame) { const frame = crashReport.error.stack.frames.find(f => f.function && f.function.includes(expectedFrame)) - assert(frame, label, `Stack frame for ${expectedFrame} successfully received.`) + assert(frame, `[${label}] Expected stack frame for ${expectedFrame} not found.`) } } @@ -102,9 +96,9 @@ async function testUnhandledNonError (label, script, { expectedFallbackType, exp const { logPayload } = await runApp(script) const crashReport = JSON.parse(logPayload.message) - assert(crashReport.error.message.includes(expectedFallbackType), label, `Fallback type "${expectedFallbackType}" captured in message.`) - assert(crashReport.error.message.includes(expectedValue), label, `Stringified value "${expectedValue}" captured in message.`) - assert(crashReport.error.stack.frames.length === 0, label, 'Empty stack trace correctly reported.') + assert(crashReport.error.message.includes(expectedFallbackType), `[${label}] Expected fallback type "${expectedFallbackType}" not found in message.`) + assert(crashReport.error.message.includes(expectedValue), `[${label}] Expected stringified value "${expectedValue}" not found in message.`) + assert.strictEqual(crashReport.error.stack.frames.length, 0, `[${label}] Expected empty stack trace but got ${crashReport.error.stack.frames.length} frames.`) } const server = app.listen(async () => {