diff --git a/crates/crashtracker/src/lib.rs b/crates/crashtracker/src/lib.rs index a908776..28366af 100644 --- a/crates/crashtracker/src/lib.rs +++ b/crates/crashtracker/src/lib.rs @@ -1,9 +1,11 @@ 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 +/// CrashtrackerConfiguration::new() constructor where the default /// signals logic exists. fn apply_default_signals( config: libdd_crashtracker::CrashtrackerConfiguration, @@ -15,7 +17,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 +29,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 +47,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 +58,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,14 +67,14 @@ 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(()) diff --git a/crates/crashtracker/src/unhandled_exception.rs b/crates/crashtracker/src/unhandled_exception.rs new file mode 100644 index 0000000..64dd8fc --- /dev/null +++ b/crates/crashtracker/src/unhandled_exception.rs @@ -0,0 +1,236 @@ +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 { + // 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()), + 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_monitor( + env: Env, + error: JsUnknown, + origin: String, +) -> napi::Result<()> { + report_unhandled(&env, error, &origin) +} + +#[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-uncaught-exception-non-error.js b/test/crashtracker/app-uncaught-exception-non-error.js new file mode 100644 index 0000000..418fa4a --- /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, 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 new file mode 100644 index 0000000..bc94be3 --- /dev/null +++ b/test/crashtracker/app-uncaught-exception.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('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) +}) + +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..fc45909 --- /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('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 new file mode 100644 index 0000000..963d60a --- /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('uncaughtExceptionMonitor', (e, origin) => { + crashtracker.reportUncaughtExceptionMonitor(e, origin) +}) + +async function myAsyncFaultyFunction () { + throw new Error('async went wrong') +} + +myAsyncFaultyFunction() 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..1118fdb 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'] @@ -13,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') @@ -26,7 +26,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 +43,92 @@ app.post('/telemetry/proxy/api/v2/apmtelemetry', (req, res) => { return } - clearTimeout(timeout) + if (!currentTest) { + throw new Error('Received unexpected crash report with no active test.') + } - server.close(() => { - const stackTrace = JSON.parse(logPayload.message).error.stack.frames + currentTest(logPayload, tags) +}) - const boomFrame = stackTrace.find(frame => frame.function?.toLowerCase().includes('segfaultify')) +let PORT - 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 runApp (script) { + return new Promise((resolve) => { + exec(`node ${script}`, { + ...opts, + env: { ...process.env, PORT } + }) - 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.') + currentTest = (logPayload, tags) => { + currentTest = null + resolve({ logPayload, tags }) } }) -}) +} -const server = app.listen(() => { - const PORT = server.address().port +async function testSegfault () { + 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')) - exec('node app', { - ...opts, - env: { - ...process.env, - PORT - } - }, e => { - if (e.signal !== 'SIGSEGV' && e.code !== 139 && e.status !== 139) { - throw e - } + 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] Expected stack frame for crashing function not found.') + } + + 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}] 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}] Expected stack frame for ${expectedFrame} not found.`) + } +} + +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}] 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 () => { + PORT = server.address().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' + }) + // 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 new file mode 100644 index 0000000..8062d9c --- /dev/null +++ b/test/crashtracker/test_utils.js @@ -0,0 +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' + ] + }) +} + +module.exports = { initTestCrashtracker }