diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 167a482e5b5f..79fd4f93541a 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -8,3 +8,4 @@ export { ContextLines } from './contextlines'; export { Context } from './context'; export { RequestData } from './requestdata'; export { LocalVariables } from './localvariables'; +export { Undici } from './undici'; diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts new file mode 100644 index 000000000000..8edc5e326cea --- /dev/null +++ b/packages/node/src/integrations/undici/index.ts @@ -0,0 +1,220 @@ +import type { Hub } from '@sentry/core'; +import type { EventProcessor, Integration } from '@sentry/types'; +import { + dynamicRequire, + dynamicSamplingContextToSentryBaggageHeader, + parseSemver, + stringMatchesSomePattern, + stripUrlQueryAndFragment, +} from '@sentry/utils'; + +import type { NodeClient } from '../../client'; +import { isSentryRequest } from '../utils/http'; +import type { DiagnosticsChannel, RequestCreateMessage, RequestEndMessage, RequestErrorMessage } from './types'; + +const NODE_VERSION = parseSemver(process.versions.node); + +export enum ChannelName { + // https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md#undicirequestcreate + RequestCreate = 'undici:request:create', + RequestEnd = 'undici:request:headers', + RequestError = 'undici:request:error', +} + +export interface UndiciOptions { + /** + * Whether breadcrumbs should be recorded for requests + * Defaults to true + */ + breadcrumbs: boolean; +} + +const DEFAULT_UNDICI_OPTIONS: UndiciOptions = { + breadcrumbs: true, +}; + +/** + * Instruments outgoing HTTP requests made with the `undici` package via + * Node's `diagnostics_channel` API. + * + * Supports Undici 4.7.0 or higher. + * + * Requires Node 16.17.0 or higher. + */ +export class Undici implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'Undici'; + + /** + * @inheritDoc + */ + public name: string = Undici.id; + + private readonly _options: UndiciOptions; + + public constructor(_options: Partial = {}) { + this._options = { + ...DEFAULT_UNDICI_OPTIONS, + ..._options, + }; + } + + /** + * @inheritDoc + */ + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + // Requires Node 16+ to use the diagnostics_channel API. + if (NODE_VERSION.major && NODE_VERSION.major < 16) { + return; + } + + let ds: DiagnosticsChannel | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + ds = dynamicRequire(module, 'diagnostics_channel') as DiagnosticsChannel; + } catch (e) { + // no-op + } + + if (!ds || !ds.subscribe) { + return; + } + + // https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md + ds.subscribe(ChannelName.RequestCreate, message => { + const { request } = message as RequestCreateMessage; + + const url = new URL(request.path, request.origin); + const stringUrl = url.toString(); + + if (isSentryRequest(stringUrl)) { + return; + } + + const hub = getCurrentHub(); + const client = hub.getClient(); + const scope = hub.getScope(); + + const activeSpan = scope.getSpan(); + + if (activeSpan && client) { + const clientOptions = client.getOptions(); + + // eslint-disable-next-line deprecation/deprecation + const shouldCreateSpan = clientOptions.shouldCreateSpanForRequest + ? // eslint-disable-next-line deprecation/deprecation + clientOptions.shouldCreateSpanForRequest(stringUrl) + : true; + + if (shouldCreateSpan) { + const data: Record = {}; + const params = url.searchParams.toString(); + if (params) { + data['http.query'] = `?${params}`; + } + if (url.hash) { + data['http.fragment'] = url.hash; + } + + const span = activeSpan.startChild({ + op: 'http.client', + description: `${request.method || 'GET'} ${stripUrlQueryAndFragment(stringUrl)}`, + data, + }); + request.__sentry__ = span; + + // eslint-disable-next-line deprecation/deprecation + const shouldPropagate = clientOptions.tracePropagationTargets + ? // eslint-disable-next-line deprecation/deprecation + stringMatchesSomePattern(stringUrl, clientOptions.tracePropagationTargets) + : true; + + if (shouldPropagate) { + // TODO: Only do this based on tracePropagationTargets + request.addHeader('sentry-trace', span.toTraceparent()); + if (span.transaction) { + const dynamicSamplingContext = span.transaction.getDynamicSamplingContext(); + const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); + if (sentryBaggageHeader) { + request.addHeader('baggage', sentryBaggageHeader); + } + } + } + } + } + }); + + ds.subscribe(ChannelName.RequestEnd, message => { + const { request, response } = message as RequestEndMessage; + + const url = new URL(request.path, request.origin); + const stringUrl = url.toString(); + + if (isSentryRequest(stringUrl)) { + return; + } + + const span = request.__sentry__; + if (span) { + span.setHttpStatus(response.statusCode); + span.finish(); + } + + if (this._options.breadcrumbs) { + getCurrentHub().addBreadcrumb( + { + category: 'http', + data: { + method: request.method, + status_code: response.statusCode, + url: stringUrl, + }, + type: 'http', + }, + { + event: 'response', + request, + response, + }, + ); + } + }); + + ds.subscribe(ChannelName.RequestError, message => { + const { request } = message as RequestErrorMessage; + + const url = new URL(request.path, request.origin); + const stringUrl = url.toString(); + + if (isSentryRequest(stringUrl)) { + return; + } + + const span = request.__sentry__; + if (span) { + span.setStatus('internal_error'); + span.finish(); + } + + if (this._options.breadcrumbs) { + getCurrentHub().addBreadcrumb( + { + category: 'http', + data: { + method: request.method, + url: stringUrl, + }, + level: 'error', + type: 'http', + }, + { + event: 'error', + request, + }, + ); + } + }); + } +} diff --git a/packages/node/src/integrations/undici/types.ts b/packages/node/src/integrations/undici/types.ts new file mode 100644 index 000000000000..9cbafeacfbb4 --- /dev/null +++ b/packages/node/src/integrations/undici/types.ts @@ -0,0 +1,252 @@ +// Vendored from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5a94716c6788f654aea7999a5fc28f4f1e7c48ad/types/node/diagnostics_channel.d.ts + +import type { Span } from '@sentry/core'; + +// License: +// This project is licensed under the MIT license. +// Copyrights are respective of each contributor listed at the beginning of each definition file. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +// documentation files(the "Software"), to deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and / or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS +// OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +// Vendored code starts here: + +type ChannelListener = (message: unknown, name: string | symbol) => void; + +/** + * The `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 '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. + * @experimental + * @see [source](https://github.com/nodejs/node/blob/v18.0.0/lib/diagnostics_channel.js) + */ +export interface DiagnosticsChannel { + /** + * 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 '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 + */ + hasSubscribers(name: string | symbol): boolean; + /** + * This is the primary entry-point for anyone wanting to interact with 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 '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 + */ + channel(name: string | symbol): Channel; + /** + * 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 '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 + */ + subscribe(name: string | symbol, onMessage: ChannelListener): void; + /** + * Remove a message handler previously registered to this channel with diagnostics_channel.subscribe(name, onMessage). + * + * ```js + * import diagnostics_channel from '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 + * @returns `true` if the handler was found, `false` otherwise + */ + unsubscribe(name: string | symbol, onMessage: ChannelListener): boolean; +} + +/** + * The class `Channel` represents an individual named channel within the data + * pipeline. It is use 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 + */ +interface ChannelI { + 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 '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; + + /** + * 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 '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 'diagnostics_channel'; + * + * const channel = diagnostics_channel.channel('my-channel'); + * + * channel.subscribe((message, name) => { + * // Received data + * }); + * ``` + * @since v15.1.0, v14.17.0 + * @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 '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 + * @param onMessage The previous subscribed handler to remove + * @return `true` if the handler was found, `false` otherwise. + */ + unsubscribe(onMessage: ChannelListener): void; +} + +export interface Channel extends ChannelI { + new (name: string | symbol): void; +} + +// https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/types/diagnostics-channel.d.ts +export interface UndiciRequest { + origin?: string | URL; + completed: boolean; + // Originally was Dispatcher.HttpMethod, but did not want to vendor that in. + method?: string; + path: string; + headers: string; + addHeader(key: string, value: string): Request; +} + +export interface UndiciResponse { + statusCode: number; + statusText: string; + headers: Array; +} + +export interface RequestWithSentry extends UndiciRequest { + __sentry__?: Span; +} + +export interface RequestCreateMessage { + request: RequestWithSentry; +} + +export interface RequestEndMessage { + request: RequestWithSentry; + response: UndiciResponse; +} + +export interface RequestErrorMessage { + request: RequestWithSentry; + error: Error; +}