From 49c84df1df2472c98660cc5744fda61c58fa4452 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 7 Oct 2024 17:24:17 +0200 Subject: [PATCH 1/7] feat(node): Add breadcrumbs for `child_process` and `worker_thread` --- .../node-integration-tests/suites/anr/test.ts | 4 +- .../suites/breadcrumbs/process-thread/app.mjs | 25 + .../suites/breadcrumbs/process-thread/test.ts | 66 +++ .../breadcrumbs/process-thread/worker.mjs | 3 + packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../src/integrations/diagnostic_channel.d.ts | 556 ++++++++++++++++++ .../node/src/integrations/processThread.ts | 132 +++++ packages/node/src/sdk/index.ts | 2 + 11 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs create mode 100644 dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts create mode 100644 dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs create mode 100644 packages/node/src/integrations/diagnostic_channel.d.ts create mode 100644 packages/node/src/integrations/processThread.ts diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index c3cb935532b1..78f89d7451c0 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -56,12 +56,12 @@ const ANR_EVENT_WITH_SCOPE = { user: { email: 'person@home.com', }, - breadcrumbs: [ + breadcrumbs: expect.arrayContaining([ { timestamp: expect.any(Number), message: 'important message!', }, - ], + ]), }; conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => { diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs new file mode 100644 index 000000000000..58c6853f41c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs @@ -0,0 +1,25 @@ +import { spawn } from 'child_process'; +import { join } from 'path'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; +import { Worker } from 'worker_threads'; + +const __dirname = new URL('.', import.meta.url).pathname; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +await new Promise(resolve => { + const child = spawn('sleep', ['1']); + child.on('exit', resolve); +}); + +await new Promise(resolve => { + const worker = new Worker(join(__dirname, 'worker.mjs')); + worker.on('exit', resolve); +}); + +throw new Error('This is a test error') diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts new file mode 100644 index 000000000000..c53658a6dbac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts @@ -0,0 +1,66 @@ +import type { Event } from '@sentry/types'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +const EVENT = { + // and an exception that is our ANR + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error', + }, + ], + }, + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'child_process', + message: 'Child process spawned', + level: 'info', + data: { + spawnfile: 'sleep', + }, + }, + { + timestamp: expect.any(Number), + category: 'child_process', + message: "Child process exited with code '0'", + level: 'info', + data: { + spawnfile: 'sleep', + }, + }, + { + timestamp: expect.any(Number), + category: 'worker_thread', + message: 'Worker thread online', + level: 'info', + data: { + threadId: expect.any(Number), + }, + }, + { + timestamp: expect.any(Number), + category: 'worker_thread', + message: "Worker thread exited with code '0'", + level: 'info', + data: { + threadId: expect.any(Number), + }, + }, + ], +}; + +conditionalTest({ min: 16 })('should capture process and thread breadcrumbs', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('ESM', done => { + createRunner(__dirname, 'app.mjs') + .withMockSentryServer() + .expect({ event: EVENT as Event }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs new file mode 100644 index 000000000000..8bab7bc566e0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs @@ -0,0 +1,3 @@ +setTimeout(() => { + // do nothing and then exit +}, 1000); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 91ea79f833bc..ccc01fe689cc 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -89,6 +89,7 @@ export { parameterize, postgresIntegration, prismaIntegration, + processThreadBreadcrumbIntegration, redisIntegration, requestDataIntegration, rewriteFramesIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7b0e05c9a48f..8f459688b09a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -101,6 +101,7 @@ export { setupNestErrorHandler, postgresIntegration, prismaIntegration, + processThreadBreadcrumbIntegration, hapiIntegration, setupHapiErrorHandler, spotlightIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index c2d743eb1bf1..404055db7c97 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -113,6 +113,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + processThreadBreadcrumbIntegration, } from '@sentry/node'; export { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bc63094e2e87..525141d4eb3f 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -31,6 +31,7 @@ export { spotlightIntegration } from './integrations/spotlight'; export { genericPoolIntegration } from './integrations/tracing/genericPool'; export { dataloaderIntegration } from './integrations/tracing/dataloader'; export { amqplibIntegration } from './integrations/tracing/amqplib'; +export { processThreadBreadcrumbIntegration } from './integrations/processThread'; export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; diff --git a/packages/node/src/integrations/diagnostic_channel.d.ts b/packages/node/src/integrations/diagnostic_channel.d.ts new file mode 100644 index 000000000000..abf3649a617f --- /dev/null +++ b/packages/node/src/integrations/diagnostic_channel.d.ts @@ -0,0 +1,556 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ + +/** + * The `node:diagnostics_channel` module provides an API to create named channels + * to report arbitrary message data for diagnostics purposes. + * + * It can be accessed using: + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * ``` + * + * It is intended that a module writer wanting to report diagnostics messages + * will create one or many top-level channels to report messages through. + * Channels may also be acquired at runtime but it is not encouraged + * due to the additional overhead of doing so. Channels may be exported for + * convenience, but as long as the name is known it can be acquired anywhere. + * + * If you intend for your module to produce diagnostics data for others to + * consume it is recommended that you include documentation of what named + * channels are used along with the shape of the message data. Channel names + * should generally include the module name to avoid collisions with data from + * other modules. + * @since v15.1.0, v14.17.0 + * @see [source](https://github.com/nodejs/node/blob/v22.x/lib/diagnostics_channel.js) + */ +declare module 'diagnostics_channel' { + import type { AsyncLocalStorage } from 'node:async_hooks'; + /** + * Check if there are active subscribers to the named channel. This is helpful if + * the message you want to send might be expensive to prepare. + * + * This API is optional but helpful when trying to publish messages from very + * performance-sensitive code. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * if (diagnostics_channel.hasSubscribers('my-channel')) { + * // There are subscribers, prepare and publish message + * } + * ``` + * @since v15.1.0, v14.17.0 + * @param name The channel name + * @return If there are active subscribers + */ + function hasSubscribers(name: string | symbol): boolean; + /** + * This is the primary entry-point for anyone wanting to publish to a named + * channel. It produces a channel object which is optimized to reduce overhead at + * publish time as much as possible. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * ``` + * @since v15.1.0, v14.17.0 + * @param name The channel name + * @return The named channel object + */ + function channel(name: string | symbol): Channel; + type ChannelListener = (message: unknown, name: string | symbol) => void; + /** + * Register a message handler to subscribe to this channel. This message handler + * will be run synchronously whenever a message is published to the channel. Any + * errors thrown in the message handler will trigger an `'uncaughtException'`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * diagnostics_channel.subscribe('my-channel', (message, name) => { + * // Received data + * }); + * ``` + * @since v18.7.0, v16.17.0 + * @param name The channel name + * @param onMessage The handler to receive channel messages + */ + function subscribe(name: string | symbol, onMessage: ChannelListener): void; + /** + * Remove a message handler previously registered to this channel with {@link subscribe}. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * function onMessage(message, name) { + * // Received data + * } + * + * diagnostics_channel.subscribe('my-channel', onMessage); + * + * diagnostics_channel.unsubscribe('my-channel', onMessage); + * ``` + * @since v18.7.0, v16.17.0 + * @param name The channel name + * @param onMessage The previous subscribed handler to remove + * @return `true` if the handler was found, `false` otherwise. + */ + function unsubscribe(name: string | symbol, onMessage: ChannelListener): boolean; + /** + * Creates a `TracingChannel` wrapper for the given `TracingChannel Channels`. If a name is given, the corresponding tracing + * channels will be created in the form of `tracing:${name}:${eventType}` where `eventType` corresponds to the types of `TracingChannel Channels`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channelsByName = diagnostics_channel.tracingChannel('my-channel'); + * + * // or... + * + * const channelsByCollection = diagnostics_channel.tracingChannel({ + * start: diagnostics_channel.channel('tracing:my-channel:start'), + * end: diagnostics_channel.channel('tracing:my-channel:end'), + * asyncStart: diagnostics_channel.channel('tracing:my-channel:asyncStart'), + * asyncEnd: diagnostics_channel.channel('tracing:my-channel:asyncEnd'), + * error: diagnostics_channel.channel('tracing:my-channel:error'), + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param nameOrChannels Channel name or object containing all the `TracingChannel Channels` + * @return Collection of channels to trace with + */ + function tracingChannel< + StoreType = unknown, + ContextType extends object = StoreType extends object ? StoreType : object, + >(nameOrChannels: string | TracingChannelCollection): TracingChannel; + /** + * The class `Channel` represents an individual named channel within the data + * pipeline. It is used to track subscribers and to publish messages when there + * are subscribers present. It exists as a separate object to avoid channel + * lookups at publish time, enabling very fast publish speeds and allowing + * for heavy use while incurring very minimal cost. Channels are created with {@link channel}, constructing a channel directly + * with `new Channel(name)` is not supported. + * @since v15.1.0, v14.17.0 + */ + class Channel { + readonly name: string | symbol; + /** + * Check if there are active subscribers to this channel. This is helpful if + * the message you want to send might be expensive to prepare. + * + * This API is optional but helpful when trying to publish messages from very + * performance-sensitive code. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * if (channel.hasSubscribers) { + * // There are subscribers, prepare and publish message + * } + * ``` + * @since v15.1.0, v14.17.0 + */ + readonly hasSubscribers: boolean; + private constructor(name: string | symbol); + /** + * Publish a message to any subscribers to the channel. This will trigger + * message handlers synchronously so they will execute within the same context. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.publish({ + * some: 'message', + * }); + * ``` + * @since v15.1.0, v14.17.0 + * @param message The message to send to the channel subscribers + */ + publish(message: unknown): void; + /** + * Register a message handler to subscribe to this channel. This message handler + * will be run synchronously whenever a message is published to the channel. Any + * errors thrown in the message handler will trigger an `'uncaughtException'`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.subscribe((message, name) => { + * // Received data + * }); + * ``` + * @since v15.1.0, v14.17.0 + * @deprecated Since v18.7.0,v16.17.0 - Use {@link subscribe(name, onMessage)} + * @param onMessage The handler to receive channel messages + */ + subscribe(onMessage: ChannelListener): void; + /** + * Remove a message handler previously registered to this channel with `channel.subscribe(onMessage)`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * function onMessage(message, name) { + * // Received data + * } + * + * channel.subscribe(onMessage); + * + * channel.unsubscribe(onMessage); + * ``` + * @since v15.1.0, v14.17.0 + * @deprecated Since v18.7.0,v16.17.0 - Use {@link unsubscribe(name, onMessage)} + * @param onMessage The previous subscribed handler to remove + * @return `true` if the handler was found, `false` otherwise. + */ + unsubscribe(onMessage: ChannelListener): void; + /** + * When `channel.runStores(context, ...)` is called, the given context data + * will be applied to any store bound to the channel. If the store has already been + * bound the previous `transform` function will be replaced with the new one. + * The `transform` function may be omitted to set the given context data as the + * context directly. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store, (data) => { + * return { data }; + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param store The store to which to bind the context data + * @param transform Transform context data before setting the store context + */ + bindStore(store: AsyncLocalStorage, transform?: (context: ContextType) => StoreType): void; + /** + * Remove a message handler previously registered to this channel with `channel.bindStore(store)`. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store); + * channel.unbindStore(store); + * ``` + * @since v19.9.0 + * @experimental + * @param store The store to unbind from the channel. + * @return `true` if the store was found, `false` otherwise. + */ + unbindStore(store: any): void; + /** + * Applies the given data to any AsyncLocalStorage instances bound to the channel + * for the duration of the given function, then publishes to the channel within + * the scope of that data is applied to the stores. + * + * If a transform function was given to `channel.bindStore(store)` it will be + * applied to transform the message data before it becomes the context value for + * the store. The prior storage context is accessible from within the transform + * function in cases where context linking is required. + * + * The context applied to the store should be accessible in any async code which + * continues from execution which began during the given function, however + * there are some situations in which `context loss` may occur. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const store = new AsyncLocalStorage(); + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.bindStore(store, (message) => { + * const parent = store.getStore(); + * return new Span(message, parent); + * }); + * channel.runStores({ some: 'message' }, () => { + * store.getStore(); // Span({ some: 'message' }) + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param context Message to send to subscribers and bind to stores + * @param fn Handler to run within the entered storage context + * @param thisArg The receiver to be used for the function call. + * @param args Optional arguments to pass to the function. + */ + runStores(): void; + } + interface TracingChannelSubscribers { + start: (message: ContextType) => void; + end: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + asyncStart: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + asyncEnd: ( + message: ContextType & { + error?: unknown; + result?: unknown; + }, + ) => void; + error: ( + message: ContextType & { + error: unknown; + }, + ) => void; + } + interface TracingChannelCollection { + start: Channel; + end: Channel; + asyncStart: Channel; + asyncEnd: Channel; + error: Channel; + } + /** + * The class `TracingChannel` is a collection of `TracingChannel Channels` which + * together express a single traceable action. It is used to formalize and + * simplify the process of producing events for tracing application flow. {@link tracingChannel} is used to construct a `TracingChannel`. As with `Channel` it is recommended to create and reuse a + * single `TracingChannel` at the top-level of the file rather than creating them + * dynamically. + * @since v19.9.0 + * @experimental + */ + class TracingChannel implements TracingChannelCollection { + start: Channel; + end: Channel; + asyncStart: Channel; + asyncEnd: Channel; + error: Channel; + /** + * Helper to subscribe a collection of functions to the corresponding channels. + * This is the same as calling `channel.subscribe(onMessage)` on each channel + * individually. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.subscribe({ + * start(message) { + * // Handle start message + * }, + * end(message) { + * // Handle end message + * }, + * asyncStart(message) { + * // Handle asyncStart message + * }, + * asyncEnd(message) { + * // Handle asyncEnd message + * }, + * error(message) { + * // Handle error message + * }, + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param subscribers Set of `TracingChannel Channels` subscribers + */ + subscribe(subscribers: TracingChannelSubscribers): void; + /** + * Helper to unsubscribe a collection of functions from the corresponding channels. + * This is the same as calling `channel.unsubscribe(onMessage)` on each channel + * individually. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.unsubscribe({ + * start(message) { + * // Handle start message + * }, + * end(message) { + * // Handle end message + * }, + * asyncStart(message) { + * // Handle asyncStart message + * }, + * asyncEnd(message) { + * // Handle asyncEnd message + * }, + * error(message) { + * // Handle error message + * }, + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param subscribers Set of `TracingChannel Channels` subscribers + * @return `true` if all handlers were successfully unsubscribed, and `false` otherwise. + */ + unsubscribe(subscribers: TracingChannelSubscribers): void; + /** + * Trace a synchronous function call. This will always produce a `start event` and `end event` around the execution and may produce an `error event` if the given function throws an error. + * This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.traceSync(() => { + * // Do something + * }, { + * some: 'thing', + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn Function to wrap a trace around + * @param context Shared object to correlate events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return The return value of the given function + */ + traceSync( + fn: (this: ThisArg, ...args: Args) => any, + context?: ContextType, + thisArg?: ThisArg, + ...args: Args + ): void; + /** + * Trace a promise-returning function call. This will always produce a `start event` and `end event` around the synchronous portion of the + * function execution, and will produce an `asyncStart event` and `asyncEnd event` when a promise continuation is reached. It may also + * produce an `error event` if the given function throws an error or the + * returned promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.tracePromise(async () => { + * // Do something + * }, { + * some: 'thing', + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn Promise-returning function to wrap a trace around + * @param context Shared object to correlate trace events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return Chained from promise returned by the given function + */ + tracePromise( + fn: (this: ThisArg, ...args: Args) => Promise, + context?: ContextType, + thisArg?: ThisArg, + ...args: Args + ): void; + /** + * Trace a callback-receiving function call. This will always produce a `start event` and `end event` around the synchronous portion of the + * function execution, and will produce a `asyncStart event` and `asyncEnd event` around the callback execution. It may also produce an `error event` if the given function throws an error or + * the returned + * promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all + * events should have any bound stores set to match this trace context. + * + * The `position` will be -1 by default to indicate the final argument should + * be used as the callback. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * + * channels.traceCallback((arg1, callback) => { + * // Do something + * callback(null, 'result'); + * }, 1, { + * some: 'thing', + * }, thisArg, arg1, callback); + * ``` + * + * The callback will also be run with `channel.runStores(context, ...)` which + * enables context loss recovery in some cases. + * + * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions + * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. + * + * ```js + * import diagnostics_channel from 'node:diagnostics_channel'; + * import { AsyncLocalStorage } from 'node:async_hooks'; + * + * const channels = diagnostics_channel.tracingChannel('my-channel'); + * const myStore = new AsyncLocalStorage(); + * + * // The start channel sets the initial store data to something + * // and stores that store data value on the trace context object + * channels.start.bindStore(myStore, (data) => { + * const span = new Span(data); + * data.span = span; + * return span; + * }); + * + * // Then asyncStart can restore from that data it stored previously + * channels.asyncStart.bindStore(myStore, (data) => { + * return data.span; + * }); + * ``` + * @since v19.9.0 + * @experimental + * @param fn callback using function to wrap a trace around + * @param position Zero-indexed argument position of expected callback + * @param context Shared object to correlate trace events through + * @param thisArg The receiver to be used for the function call + * @param args Optional arguments to pass to the function + * @return The return value of the given function + */ + traceCallback any>( + fn: Fn, + position?: number, + context?: ContextType, + thisArg?: any, + ...args: Parameters + ): void; + } +} +declare module 'node:diagnostics_channel' { + export * from 'diagnostics_channel'; +} diff --git a/packages/node/src/integrations/processThread.ts b/packages/node/src/integrations/processThread.ts new file mode 100644 index 000000000000..a76313489fa8 --- /dev/null +++ b/packages/node/src/integrations/processThread.ts @@ -0,0 +1,132 @@ +import type { ChildProcess } from 'node:child_process'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Worker } from 'node:worker_threads'; +import { addBreadcrumb, defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; + +interface Options { + /** + * Whether to include child process arguments in breadcrumbs data. + * + * @default false + */ + includeChildProcessArgs?: boolean; +} + +const INTEGRATION_NAME = 'ProcessAndThreadBreadcrumbs'; + +const _processThreadBreadcrumbIntegration = ((options: Options = {}) => { + return { + name: INTEGRATION_NAME, + setup(_client) { + // eslint-disable-next-line deprecation/deprecation + diagnosticsChannel.channel('child_process').subscribe((event: unknown) => { + if (event && typeof event === 'object' && 'process' in event) { + captureChildProcessEvents(event.process as ChildProcess, options); + } + }); + + // eslint-disable-next-line deprecation/deprecation + diagnosticsChannel.channel('worker_threads').subscribe((event: unknown) => { + if (event && typeof event === 'object' && 'worker' in event) { + captureWorkerThreadEvents(event.worker as Worker); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Capture breadcrumbs for child process and thread creation, exit and error. + */ +export const processThreadBreadcrumbIntegration = defineIntegration(_processThreadBreadcrumbIntegration); + +function captureChildProcessEvents(child: ChildProcess, options: Options): void { + let hasExited = false; + + child.on('spawn', () => { + // This is Sentry getting macOS OS context + if (child.spawnfile === '/usr/bin/sw_vers') { + hasExited = true; + return; + } + + const data: Record = { spawnfile: child.spawnfile }; + if (options.includeChildProcessArgs) { + data.spawnargs = child.spawnargs; + } + + addBreadcrumb({ + category: 'child_process', + message: 'Child process spawned', + level: 'info', + data, + }); + }); + + child.on('exit', (code, signal) => { + if (!hasExited) { + hasExited = true; + + let message = 'Child process exited'; + if (code !== null) { + message += ` with code '${code}'`; + } + if (signal !== null) { + message += ` with signal '${signal}'`; + } + + addBreadcrumb({ + category: 'child_process', + message, + level: code !== 0 ? 'warning' : 'info', + data, + }); + } + }); + + child.on('error', error => { + if (!hasExited) { + hasExited = true; + + addBreadcrumb({ + category: 'child_process', + message: `Child process errored with ${error.message}`, + level: 'error', + data, + }); + } + }); +} + +function captureWorkerThreadEvents(worker: Worker): void { + let threadId: number | undefined; + + worker + .on('online', () => { + threadId = worker.threadId; + + addBreadcrumb({ + category: 'worker_thread', + message: 'Worker thread online', + level: 'info', + data: { threadId }, + }); + }) + .on('exit', code => { + addBreadcrumb({ + category: 'worker_thread', + message: `Worker thread exited with code '${code}'`, + level: code !== 0 ? 'warning' : 'info', + data: { threadId }, + }); + }) + .on('error', error => { + addBreadcrumb({ + category: 'worker_thread', + message: `Worker thread errored with ${error.message}`, + level: 'error', + data: { threadId }, + }); + }); +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 7276e809875a..87d61cc908bc 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -36,6 +36,7 @@ import { modulesIntegration } from '../integrations/modules'; import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexception'; import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection'; +import { processThreadBreadcrumbIntegration } from '../integrations/processThread'; import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight'; import { getAutoPerformanceIntegrations } from '../integrations/tracing'; import { makeNodeTransport } from '../transports'; @@ -71,6 +72,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { contextLinesIntegration(), localVariablesIntegration(), nodeContextIntegration(), + processThreadBreadcrumbIntegration(), ...getCjsOnlyIntegrations(), ]; } From 1698ea73219fbddb39a425aa904015455c5ac9c7 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 7 Oct 2024 17:36:03 +0200 Subject: [PATCH 2/7] Fix build --- packages/node/src/integrations/processThread.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node/src/integrations/processThread.ts b/packages/node/src/integrations/processThread.ts index a76313489fa8..c30b38e0b1ed 100644 --- a/packages/node/src/integrations/processThread.ts +++ b/packages/node/src/integrations/processThread.ts @@ -43,6 +43,7 @@ export const processThreadBreadcrumbIntegration = defineIntegration(_processThre function captureChildProcessEvents(child: ChildProcess, options: Options): void { let hasExited = false; + let data: Record | undefined; child.on('spawn', () => { // This is Sentry getting macOS OS context @@ -51,7 +52,7 @@ function captureChildProcessEvents(child: ChildProcess, options: Options): void return; } - const data: Record = { spawnfile: child.spawnfile }; + data = { spawnfile: child.spawnfile }; if (options.includeChildProcessArgs) { data.spawnargs = child.spawnargs; } From 2926408dae7f13306023beb4cfb10b57b12d32b9 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 7 Oct 2024 17:54:27 +0200 Subject: [PATCH 3/7] Lint --- .../suites/breadcrumbs/process-thread/app.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs index 58c6853f41c5..3f3f88965f6f 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs @@ -22,4 +22,4 @@ await new Promise(resolve => { worker.on('exit', resolve); }); -throw new Error('This is a test error') +throw new Error('This is a test error'); From d338d73f0d8dfb1e184355a036fe83fe28fc5873 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 7 Oct 2024 17:55:41 +0200 Subject: [PATCH 4/7] Only Node v20+ --- .../suites/breadcrumbs/process-thread/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts index c53658a6dbac..86c922cb0008 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts @@ -52,7 +52,7 @@ const EVENT = { ], }; -conditionalTest({ min: 16 })('should capture process and thread breadcrumbs', () => { +conditionalTest({ min: 20 })('should capture process and thread breadcrumbs', () => { afterAll(() => { cleanupChildProcesses(); }); From 5168f1307c2c78fd37c9dbbe1fbb267d88870068 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 8 Oct 2024 14:19:54 +0200 Subject: [PATCH 5/7] Fix test types --- packages/node/tsconfig.test.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/tsconfig.test.json b/packages/node/tsconfig.test.json index 87f6afa06b86..b0c6b000999b 100644 --- a/packages/node/tsconfig.test.json +++ b/packages/node/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*"], + "include": ["test/**/*", "./src/integrations/diagnostic_channel.d.ts"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used From 568d7c9d3acb0eeb8220ddc482996d2a63594d98 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 8 Oct 2024 22:56:20 +0100 Subject: [PATCH 6/7] Exclude from Bun export tests --- .../node-exports-test-app/scripts/consistentExports.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index a35bf4657c64..546639e8a766 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -50,6 +50,8 @@ const DEPENDENTS: Dependent[] = [ ignoreExports: [ // not supported in bun: 'NodeClient', + // Bun doesn't emit the required diagnostics_channel events + 'processThreadBreadcrumbIntegration', ], }, { From f20c2bba2f03277ff2218c0abd1caf5a6e1cc16a Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 22 Oct 2024 18:55:19 +0200 Subject: [PATCH 7/7] Don't capture start/spawn breadcrumbs --- .../suites/breadcrumbs/process-thread/app.mjs | 4 +- .../suites/breadcrumbs/process-thread/test.ts | 26 +---- .../breadcrumbs/process-thread/worker.mjs | 4 +- .../node/src/integrations/processThread.ts | 106 +++++++----------- 4 files changed, 47 insertions(+), 93 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs index 3f3f88965f6f..903470806ad9 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/app.mjs @@ -13,12 +13,14 @@ Sentry.init({ }); await new Promise(resolve => { - const child = spawn('sleep', ['1']); + const child = spawn('sleep', ['a']); + child.on('error', resolve); child.on('exit', resolve); }); await new Promise(resolve => { const worker = new Worker(join(__dirname, 'worker.mjs')); + worker.on('error', resolve); worker.on('exit', resolve); }); diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts index 86c922cb0008..f675ca4250dd 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/test.ts @@ -16,35 +16,17 @@ const EVENT = { { timestamp: expect.any(Number), category: 'child_process', - message: 'Child process spawned', - level: 'info', + message: "Child process exited with code '1'", + level: 'warning', data: { spawnfile: 'sleep', }, }, - { - timestamp: expect.any(Number), - category: 'child_process', - message: "Child process exited with code '0'", - level: 'info', - data: { - spawnfile: 'sleep', - }, - }, - { - timestamp: expect.any(Number), - category: 'worker_thread', - message: 'Worker thread online', - level: 'info', - data: { - threadId: expect.any(Number), - }, - }, { timestamp: expect.any(Number), category: 'worker_thread', - message: "Worker thread exited with code '0'", - level: 'info', + message: "Worker thread errored with 'Worker error'", + level: 'error', data: { threadId: expect.any(Number), }, diff --git a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs index 8bab7bc566e0..049063bd26b4 100644 --- a/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs +++ b/dev-packages/node-integration-tests/suites/breadcrumbs/process-thread/worker.mjs @@ -1,3 +1 @@ -setTimeout(() => { - // do nothing and then exit -}, 1000); +throw new Error('Worker error'); diff --git a/packages/node/src/integrations/processThread.ts b/packages/node/src/integrations/processThread.ts index c30b38e0b1ed..870a0dc6df64 100644 --- a/packages/node/src/integrations/processThread.ts +++ b/packages/node/src/integrations/processThread.ts @@ -37,7 +37,7 @@ const _processThreadBreadcrumbIntegration = ((options: Options = {}) => { }) satisfies IntegrationFn; /** - * Capture breadcrumbs for child process and thread creation, exit and error. + * Capture breadcrumbs for child processes and worker threads. */ export const processThreadBreadcrumbIntegration = defineIntegration(_processThreadBreadcrumbIntegration); @@ -45,59 +45,46 @@ function captureChildProcessEvents(child: ChildProcess, options: Options): void let hasExited = false; let data: Record | undefined; - child.on('spawn', () => { - // This is Sentry getting macOS OS context - if (child.spawnfile === '/usr/bin/sw_vers') { - hasExited = true; - return; - } - - data = { spawnfile: child.spawnfile }; - if (options.includeChildProcessArgs) { - data.spawnargs = child.spawnargs; - } - - addBreadcrumb({ - category: 'child_process', - message: 'Child process spawned', - level: 'info', - data, - }); - }); - - child.on('exit', (code, signal) => { - if (!hasExited) { - hasExited = true; + child + .on('spawn', () => { + // This is Sentry getting macOS OS context + if (child.spawnfile === '/usr/bin/sw_vers') { + hasExited = true; + return; + } - let message = 'Child process exited'; - if (code !== null) { - message += ` with code '${code}'`; + data = { spawnfile: child.spawnfile }; + if (options.includeChildProcessArgs) { + data.spawnargs = child.spawnargs; } - if (signal !== null) { - message += ` with signal '${signal}'`; + }) + .on('exit', code => { + if (!hasExited) { + hasExited = true; + + // Only log for non-zero exit codes + if (code !== null && code !== 0) { + addBreadcrumb({ + category: 'child_process', + message: `Child process exited with code '${code}'`, + level: 'warning', + data, + }); + } } - - addBreadcrumb({ - category: 'child_process', - message, - level: code !== 0 ? 'warning' : 'info', - data, - }); - } - }); - - child.on('error', error => { - if (!hasExited) { - hasExited = true; - - addBreadcrumb({ - category: 'child_process', - message: `Child process errored with ${error.message}`, - level: 'error', - data, - }); - } - }); + }) + .on('error', error => { + if (!hasExited) { + hasExited = true; + + addBreadcrumb({ + category: 'child_process', + message: `Child process errored with '${error.message}'`, + level: 'error', + data, + }); + } + }); } function captureWorkerThreadEvents(worker: Worker): void { @@ -106,26 +93,11 @@ function captureWorkerThreadEvents(worker: Worker): void { worker .on('online', () => { threadId = worker.threadId; - - addBreadcrumb({ - category: 'worker_thread', - message: 'Worker thread online', - level: 'info', - data: { threadId }, - }); - }) - .on('exit', code => { - addBreadcrumb({ - category: 'worker_thread', - message: `Worker thread exited with code '${code}'`, - level: code !== 0 ? 'warning' : 'info', - data: { threadId }, - }); }) .on('error', error => { addBreadcrumb({ category: 'worker_thread', - message: `Worker thread errored with ${error.message}`, + message: `Worker thread errored with '${error.message}'`, level: 'error', data: { threadId }, });