From 41489e7f0c45447b66411cc85065ecb02de2ecd0 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 4 Jul 2023 10:49:00 -0400 Subject: [PATCH] feat(node): Add tracing without performance to Node Undici --- .../node/src/integrations/undici/index.ts | 325 ++++++++++-------- .../node/src/integrations/undici/types.ts | 3 +- .../node/test/integrations/undici.test.ts | 139 +++++--- 3 files changed, 268 insertions(+), 199 deletions(-) diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts index 38f920283b7e..0c69dec37d3f 100644 --- a/packages/node/src/integrations/undici/index.ts +++ b/packages/node/src/integrations/undici/index.ts @@ -1,8 +1,9 @@ -import type { Hub } from '@sentry/core'; -import type { EventProcessor, Integration } from '@sentry/types'; +import { getCurrentHub, getDynamicSamplingContextFromClient } from '@sentry/core'; +import type { EventProcessor, Integration, Span } from '@sentry/types'; import { dynamicRequire, dynamicSamplingContextToSentryBaggageHeader, + generateSentryTraceHeader, getSanitizedUrlString, parseUrl, stringMatchesSomePattern, @@ -12,7 +13,13 @@ import { LRUMap } from 'lru_map'; import type { NodeClient } from '../../client'; import { NODE_VERSION } from '../../nodeVersion'; import { isSentryRequest } from '../utils/http'; -import type { DiagnosticsChannel, RequestCreateMessage, RequestEndMessage, RequestErrorMessage } from './types'; +import type { + DiagnosticsChannel, + RequestCreateMessage, + RequestEndMessage, + RequestErrorMessage, + RequestWithSentry, +} from './types'; export enum ChannelName { // https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md#undicirequestcreate @@ -81,7 +88,7 @@ export class Undici implements Integration { /** * @inheritDoc */ - public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void): void { // Requires Node 16+ to use the diagnostics_channel API. if (NODE_VERSION.major && NODE_VERSION.major < 16) { return; @@ -99,169 +106,205 @@ export class Undici implements Integration { return; } - const shouldCreateSpan = (url: string): boolean => { - if (this._options.shouldCreateSpanForRequest === undefined) { + // https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md + ds.subscribe(ChannelName.RequestCreate, this._onRequestCreate); + ds.subscribe(ChannelName.RequestEnd, this._onRequestEnd); + ds.subscribe(ChannelName.RequestError, this._onRequestError); + } + + /** Helper that wraps shouldCreateSpanForRequest option */ + private _shouldCreateSpan(url: string): boolean { + if (this._options.shouldCreateSpanForRequest === undefined) { + return true; + } + + const cachedDecision = this._createSpanUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = this._options.shouldCreateSpanForRequest(url); + this._createSpanUrlMap.set(url, decision); + return decision; + } + + private _onRequestCreate = (message: unknown): void => { + const hub = getCurrentHub(); + if (!hub.getIntegration(Undici)) { + return; + } + + const { request } = message as RequestCreateMessage; + + const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; + + if (isSentryRequest(stringUrl) || request.__sentry_span__ !== undefined) { + return; + } + + const client = hub.getClient(); + if (!client) { + return; + } + + const clientOptions = client.getOptions(); + const scope = hub.getScope(); + + const parentSpan = scope.getSpan(); + + const span = this._shouldCreateSpan(stringUrl) ? createRequestSpan(parentSpan, request, stringUrl) : undefined; + if (span) { + request.__sentry_span__ = span; + } + + const shouldAttachTraceData = (url: string): boolean => { + if (clientOptions.tracePropagationTargets === undefined) { return true; } - const cachedDecision = this._createSpanUrlMap.get(url); + const cachedDecision = this._headersUrlMap.get(url); if (cachedDecision !== undefined) { return cachedDecision; } - const decision = this._options.shouldCreateSpanForRequest(url); - this._createSpanUrlMap.set(url, decision); + const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); + this._headersUrlMap.set(url, decision); return decision; }; - // https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md - ds.subscribe(ChannelName.RequestCreate, message => { - const hub = getCurrentHub(); - if (!hub.getIntegration(Undici)) { - return; + if (shouldAttachTraceData(stringUrl)) { + if (span) { + const dynamicSamplingContext = span?.transaction?.getDynamicSamplingContext(); + const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); + + setHeadersOnRequest(request, span.toTraceparent(), sentryBaggageHeader); + } else { + const { traceId, sampled, dsc } = scope.getPropagationContext(); + const sentryTrace = generateSentryTraceHeader(traceId, undefined, sampled); + const dynamicSamplingContext = dsc || getDynamicSamplingContextFromClient(traceId, client, scope); + const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); + setHeadersOnRequest(request, sentryTrace, sentryBaggageHeader); } + } + }; - const { request } = message as RequestCreateMessage; + private _onRequestEnd = (message: unknown): void => { + const hub = getCurrentHub(); + if (!hub.getIntegration(Undici)) { + return; + } - const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - const url = parseUrl(stringUrl); + const { request, response } = message as RequestEndMessage; - if (isSentryRequest(stringUrl) || request.__sentry__ !== undefined) { - return; - } + const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - const client = hub.getClient(); - const scope = hub.getScope(); - - const activeSpan = scope.getSpan(); - - if (activeSpan && client) { - const clientOptions = client.getOptions(); - - if (shouldCreateSpan(stringUrl)) { - const method = request.method || 'GET'; - const data: Record = { - 'http.method': method, - }; - if (url.search) { - data['http.query'] = url.search; - } - if (url.hash) { - data['http.fragment'] = url.hash; - } - const span = activeSpan.startChild({ - op: 'http.client', - description: `${method} ${getSanitizedUrlString(url)}`, - data, - }); - request.__sentry__ = span; - - const shouldAttachTraceData = (url: string): boolean => { - if (clientOptions.tracePropagationTargets === undefined) { - return true; - } - - const cachedDecision = this._headersUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = stringMatchesSomePattern(url, clientOptions.tracePropagationTargets); - this._headersUrlMap.set(url, decision); - return decision; - }; - - if (shouldAttachTraceData(stringUrl)) { - request.addHeader('sentry-trace', span.toTraceparent()); - if (span.transaction) { - const dynamicSamplingContext = span.transaction.getDynamicSamplingContext(); - const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext); - if (sentryBaggageHeader) { - request.addHeader('baggage', sentryBaggageHeader); - } - } - } - } - } - }); + if (isSentryRequest(stringUrl)) { + return; + } - ds.subscribe(ChannelName.RequestEnd, message => { - const hub = getCurrentHub(); - if (!hub.getIntegration(Undici)) { - return; - } + const span = request.__sentry_span__; + if (span) { + span.setHttpStatus(response.statusCode); + span.finish(); + } - const { request, response } = message as RequestEndMessage; + if (this._options.breadcrumbs) { + hub.addBreadcrumb( + { + category: 'http', + data: { + method: request.method, + status_code: response.statusCode, + url: stringUrl, + }, + type: 'http', + }, + { + event: 'response', + request, + response, + }, + ); + } + }; - const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; + private _onRequestError = (message: unknown): void => { + const hub = getCurrentHub(); + if (!hub.getIntegration(Undici)) { + return; + } - if (isSentryRequest(stringUrl)) { - return; - } + const { request } = message as RequestErrorMessage; - const span = request.__sentry__; - if (span) { - span.setHttpStatus(response.statusCode); - span.finish(); - } + const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; - if (this._options.breadcrumbs) { - hub.addBreadcrumb( - { - category: 'http', - data: { - method: request.method, - status_code: response.statusCode, - url: stringUrl, - }, - type: 'http', - }, - { - event: 'response', - request, - response, - }, - ); - } - }); + if (isSentryRequest(stringUrl)) { + return; + } - ds.subscribe(ChannelName.RequestError, message => { - const hub = getCurrentHub(); - if (!hub.getIntegration(Undici)) { - return; - } + const span = request.__sentry_span__; + if (span) { + span.setStatus('internal_error'); + span.finish(); + } - const { request } = message as RequestErrorMessage; + if (this._options.breadcrumbs) { + hub.addBreadcrumb( + { + category: 'http', + data: { + method: request.method, + url: stringUrl, + }, + level: 'error', + type: 'http', + }, + { + event: 'error', + request, + }, + ); + } + }; +} - const stringUrl = request.origin ? request.origin.toString() + request.path : request.path; +function setHeadersOnRequest( + request: RequestWithSentry, + sentryTrace: string, + sentryBaggageHeader: string | undefined, +): void { + if (request.__sentry_has_headers__) { + return; + } - if (isSentryRequest(stringUrl)) { - return; - } + request.addHeader('sentry-trace', sentryTrace); + if (sentryBaggageHeader) { + request.addHeader('baggage', sentryBaggageHeader); + } - const span = request.__sentry__; - if (span) { - span.setStatus('internal_error'); - span.finish(); - } + request.__sentry_has_headers__ = true; +} - if (this._options.breadcrumbs) { - hub.addBreadcrumb( - { - category: 'http', - data: { - method: request.method, - url: stringUrl, - }, - level: 'error', - type: 'http', - }, - { - event: 'error', - request, - }, - ); - } - }); +function createRequestSpan( + activeSpan: Span | undefined, + request: RequestWithSentry, + stringUrl: string, +): Span | undefined { + const url = parseUrl(stringUrl); + + const method = request.method || 'GET'; + const data: Record = { + 'http.method': method, + }; + if (url.search) { + data['http.query'] = url.search; + } + if (url.hash) { + data['http.fragment'] = url.hash; } + return activeSpan?.startChild({ + op: 'http.client', + description: `${method} ${getSanitizedUrlString(url)}`, + data, + }); } diff --git a/packages/node/src/integrations/undici/types.ts b/packages/node/src/integrations/undici/types.ts index c2d2db125195..f56e708f456c 100644 --- a/packages/node/src/integrations/undici/types.ts +++ b/packages/node/src/integrations/undici/types.ts @@ -234,7 +234,8 @@ export interface UndiciResponse { } export interface RequestWithSentry extends UndiciRequest { - __sentry__?: Span; + __sentry_span__?: Span; + __sentry_has_headers__?: boolean; } export interface RequestCreateMessage { diff --git a/packages/node/test/integrations/undici.test.ts b/packages/node/test/integrations/undici.test.ts index b719d579dcbe..f2587f75f463 100644 --- a/packages/node/test/integrations/undici.test.ts +++ b/packages/node/test/integrations/undici.test.ts @@ -1,5 +1,5 @@ import type { Transaction } from '@sentry/core'; -import { Hub, makeMain } from '@sentry/core'; +import { Hub, makeMain, runWithAsyncContext } from '@sentry/core'; import * as http from 'http'; import type { fetch as FetchType } from 'undici'; @@ -15,8 +15,8 @@ let hub: Hub; let fetch: typeof FetchType; beforeAll(async () => { - await setupTestServer(); try { + await setupTestServer(); // need to conditionally require `undici` because it's not available in Node 10 // eslint-disable-next-line @typescript-eslint/no-var-requires fetch = require('undici').fetch; @@ -28,7 +28,7 @@ beforeAll(async () => { const DEFAULT_OPTIONS = getDefaultNodeClientOptions({ dsn: SENTRY_DSN, - tracesSampleRate: 1, + tracesSampler: () => true, integrations: [new Undici()], }); @@ -51,10 +51,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { it.each([ [ 'simple url', - 'http://localhost:18099', + 'http://localhost:18100', undefined, { - description: 'GET http://localhost:18099/', + description: 'GET http://localhost:18100/', op: 'http.client', data: expect.objectContaining({ 'http.method': 'GET', @@ -63,10 +63,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { ], [ 'url with query', - 'http://localhost:18099?foo=bar', + 'http://localhost:18100?foo=bar', undefined, { - description: 'GET http://localhost:18099/', + description: 'GET http://localhost:18100/', op: 'http.client', data: expect.objectContaining({ 'http.method': 'GET', @@ -76,10 +76,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { ], [ 'url with POST method', - 'http://localhost:18099', + 'http://localhost:18100', { method: 'POST' }, { - description: 'POST http://localhost:18099/', + description: 'POST http://localhost:18100/', data: expect.objectContaining({ 'http.method': 'POST', }), @@ -87,10 +87,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { ], [ 'url with POST method', - 'http://localhost:18099', + 'http://localhost:18100', { method: 'POST' }, { - description: 'POST http://localhost:18099/', + description: 'POST http://localhost:18100/', data: expect.objectContaining({ 'http.method': 'POST', }), @@ -98,10 +98,10 @@ conditionalTest({ min: 16 })('Undici integration', () => { ], [ 'url with GET as default', - 'http://localhost:18099', + 'http://localhost:18100', { method: undefined }, { - description: 'GET http://localhost:18099/', + description: 'GET http://localhost:18100/', }, ], ])('creates a span with a %s', async (_: string, request, requestInit, expected) => { @@ -180,50 +180,78 @@ conditionalTest({ min: 16 })('Undici integration', () => { const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction; hub.getScope().setSpan(transaction); - const undoPatch = patchUndici(hub, { shouldCreateSpanForRequest: url => url.includes('yes') }); + const undoPatch = patchUndici({ shouldCreateSpanForRequest: url => url.includes('yes') }); - await fetch('http://localhost:18099/no', { method: 'POST' }); + await fetch('http://localhost:18100/no', { method: 'POST' }); expect(transaction.spanRecorder?.spans.length).toBe(1); - await fetch('http://localhost:18099/yes', { method: 'POST' }); + await fetch('http://localhost:18100/yes', { method: 'POST' }); expect(transaction.spanRecorder?.spans.length).toBe(2); undoPatch(); }); - it('attaches the sentry trace and baggage headers', async () => { - const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction; - hub.getScope().setSpan(transaction); + it('attaches the sentry trace and baggage headers if there is an active span', async () => { + expect.assertions(3); - await fetch('http://localhost:18099', { method: 'POST' }); + await runWithAsyncContext(async () => { + const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction; + hub.getScope().setSpan(transaction); - expect(transaction.spanRecorder?.spans.length).toBe(2); - const span = transaction.spanRecorder?.spans[1]; + await fetch('http://localhost:18100', { method: 'POST' }); + + expect(transaction.spanRecorder?.spans.length).toBe(2); + const span = transaction.spanRecorder?.spans[1]; + + expect(requestHeaders['sentry-trace']).toEqual(span?.toTraceparent()); + expect(requestHeaders['baggage']).toEqual( + `sentry-environment=production,sentry-public_key=0,sentry-trace_id=${transaction.traceId},sentry-sample_rate=1,sentry-transaction=test-transaction`, + ); + }); + }); + + it('attaches the sentry trace and baggage headers if there is no active span', async () => { + const scope = hub.getScope(); - expect(requestHeaders['sentry-trace']).toEqual(span?.toTraceparent()); + await fetch('http://localhost:18100', { method: 'POST' }); + + const propagationContext = scope.getPropagationContext(); + + expect(requestHeaders['sentry-trace'].includes(propagationContext.traceId)).toBe(true); expect(requestHeaders['baggage']).toEqual( - `sentry-environment=production,sentry-public_key=0,sentry-trace_id=${transaction.traceId},sentry-sample_rate=1,sentry-transaction=test-transaction`, + `sentry-environment=production,sentry-public_key=0,sentry-trace_id=${propagationContext.traceId}`, ); }); - it('does not attach headers if `shouldCreateSpanForRequest` does not create a span', async () => { + it('attaches headers if `shouldCreateSpanForRequest` does not create a span using propagation context', async () => { const transaction = hub.startTransaction({ name: 'test-transaction' }) as Transaction; - hub.getScope().setSpan(transaction); + const scope = hub.getScope(); + const propagationContext = scope.getPropagationContext(); - const undoPatch = patchUndici(hub, { shouldCreateSpanForRequest: url => url.includes('yes') }); + scope.setSpan(transaction); - await fetch('http://localhost:18099/no', { method: 'POST' }); + const undoPatch = patchUndici({ shouldCreateSpanForRequest: url => url.includes('yes') }); - expect(requestHeaders['sentry-trace']).toBeUndefined(); - expect(requestHeaders['baggage']).toBeUndefined(); + await fetch('http://localhost:18100/no', { method: 'POST' }); + + expect(requestHeaders['sentry-trace']).toBeDefined(); + expect(requestHeaders['baggage']).toBeDefined(); + + expect(requestHeaders['sentry-trace'].includes(propagationContext.traceId)).toBe(true); + const firstSpanId = requestHeaders['sentry-trace'].split('-')[1]; - await fetch('http://localhost:18099/yes', { method: 'POST' }); + await fetch('http://localhost:18100/yes', { method: 'POST' }); expect(requestHeaders['sentry-trace']).toBeDefined(); expect(requestHeaders['baggage']).toBeDefined(); + expect(requestHeaders['sentry-trace'].includes(propagationContext.traceId)).toBe(false); + + const secondSpanId = requestHeaders['sentry-trace'].split('-')[1]; + expect(firstSpanId).not.toBe(secondSpanId); + undoPatch(); }); @@ -236,14 +264,14 @@ conditionalTest({ min: 16 })('Undici integration', () => { expect(transaction.spanRecorder?.spans.length).toBe(1); - await fetch('http://localhost:18099/no', { method: 'POST' }); + await fetch('http://localhost:18100/no', { method: 'POST' }); expect(transaction.spanRecorder?.spans.length).toBe(2); expect(requestHeaders['sentry-trace']).toBeUndefined(); expect(requestHeaders['baggage']).toBeUndefined(); - await fetch('http://localhost:18099/yes', { method: 'POST' }); + await fetch('http://localhost:18100/yes', { method: 'POST' }); expect(transaction.spanRecorder?.spans.length).toBe(3); @@ -262,7 +290,7 @@ conditionalTest({ min: 16 })('Undici integration', () => { data: { method: 'POST', status_code: 200, - url: 'http://localhost:18099/', + url: 'http://localhost:18100/', }, type: 'http', timestamp: expect.any(Number), @@ -272,7 +300,7 @@ conditionalTest({ min: 16 })('Undici integration', () => { }); hub.bindClient(client); - await fetch('http://localhost:18099', { method: 'POST' }); + await fetch('http://localhost:18100', { method: 'POST' }); }); it('adds a breadcrumb on errored request', async () => { @@ -306,9 +334,9 @@ conditionalTest({ min: 16 })('Undici integration', () => { it('does not add a breadcrumb if disabled', async () => { expect.assertions(0); - const undoPatch = patchUndici(hub, { breadcrumbs: false }); + const undoPatch = patchUndici({ breadcrumbs: false }); - await fetch('http://localhost:18099', { method: 'POST' }); + await fetch('http://localhost:18100', { method: 'POST' }); undoPatch(); }); @@ -351,37 +379,34 @@ function setupTestServer() { res.end(); // also terminate socket because keepalive hangs connection a bit - res.connection.end(); + res.connection?.end(); }); - testServer.listen(18099, 'localhost'); + testServer?.listen(18100); return new Promise(resolve => { testServer?.on('listening', resolve); }); } -function patchUndici(hub: Hub, userOptions: Partial): () => void { - let options: any = {}; - const client = hub.getClient(); - if (client) { - const undici = client.getIntegration(Undici); - if (undici) { - // @ts-ignore need to access private property - options = { ...undici._options }; - // @ts-ignore need to access private property - undici._options = Object.assign(undici._options, userOptions); - } +function patchUndici(userOptions: Partial): () => void { + try { + const undici = hub.getClient()!.getIntegration(Undici); + // @ts-ignore need to access private property + options = { ...undici._options }; + // @ts-ignore need to access private property + undici._options = Object.assign(undici._options, userOptions); + } catch (_) { + throw new Error('Could not undo patching of undici'); } return () => { - const client = hub.getClient(); - if (client) { - const undici = client.getIntegration(Undici); - if (undici) { - // @ts-ignore need to access private property - undici._options = { ...options }; - } + try { + const undici = hub.getClient()!.getIntegration(Undici); + // @ts-expect-error Need to override readonly property + undici!['_options'] = { ...options }; + } catch (_) { + throw new Error('Could not undo patching of undici'); } }; }