diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreOutgoingRequests.js b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreOutgoingRequests.js index 9d7d2ed069d1..b42fa97fab08 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreOutgoingRequests.js +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server-ignoreOutgoingRequests.js @@ -11,10 +11,11 @@ Sentry.init({ integrations: [ Sentry.httpIntegration({ ignoreOutgoingRequests: (url, request) => { - if (url.includes('example.com')) { + if (url === 'http://example.com/blockUrl') { return true; } - if (request.method === 'POST' && request.path === '/path') { + + if (request.hostname === 'example.com' && request.path === '/blockRequest') { return true; } return false; @@ -32,28 +33,37 @@ const app = express(); app.use(cors()); -app.get('/test', (_req, response) => { - http - .request('http://example.com/', res => { - res.on('data', () => {}); - res.on('end', () => { - response.send({ response: 'done' }); - }); - }) - .end(); +app.get('/testUrl', (_req, response) => { + makeHttpRequest('http://example.com/blockUrl').then(() => { + makeHttpRequest('http://example.com/pass').then(() => { + response.send({ response: 'done' }); + }); + }); }); -app.post('/testPath', (_req, response) => { - http - .request('http://example.com/path', res => { - res.on('data', () => {}); - res.on('end', () => { - response.send({ response: 'done' }); - }); - }) - .end(); +app.get('/testRequest', (_req, response) => { + makeHttpRequest('http://example.com/blockRequest').then(() => { + makeHttpRequest('http://example.com/pass').then(() => { + response.send({ response: 'done' }); + }); + }); }); Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); + +function makeHttpRequest(url) { + return new Promise((resolve, reject) => { + http + .get(url, res => { + res.on('data', () => {}); + res.on('end', () => { + resolve(); + }); + }) + .on('error', error => { + reject(error); + }); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 972ba30eab43..016ad078d34e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -128,65 +128,45 @@ describe('httpIntegration', () => { }); }); - describe("doesn't create child spans for outgoing requests ignored via `ignoreOutgoingRequests`", () => { + describe("doesn't create child spans or breadcrumbs for outgoing requests ignored via `ignoreOutgoingRequests`", () => { test('via the url param', done => { const runner = createRunner(__dirname, 'server-ignoreOutgoingRequests.js') .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - url: expect.stringMatching(/\/test$/), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - transaction: 'GET /test', - spans: [ - expect.objectContaining({ op: 'middleware.express', description: 'query' }), - expect.objectContaining({ op: 'middleware.express', description: 'expressInit' }), - expect.objectContaining({ op: 'middleware.express', description: 'corsMiddleware' }), - expect.objectContaining({ op: 'request_handler.express', description: '/test' }), - ], + transaction: event => { + expect(event.transaction).toBe('GET /testUrl'); + + const requestSpans = event.spans?.filter(span => span.op === 'http.client'); + expect(requestSpans).toHaveLength(1); + expect(requestSpans![0]?.description).toBe('GET http://example.com/pass'); + + const breadcrumbs = event.breadcrumbs?.filter(b => b.category === 'http'); + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs![0]?.data?.url).toEqual('http://example.com/pass'); }, }) .start(done); - runner.makeRequest('get', '/test'); + runner.makeRequest('get', '/testUrl'); }); test('via the request param', done => { const runner = createRunner(__dirname, 'server-ignoreOutgoingRequests.js') .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - data: { - url: expect.stringMatching(/\/testPath$/), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - transaction: 'POST /testPath', - spans: [ - expect.objectContaining({ op: 'middleware.express', description: 'query' }), - expect.objectContaining({ op: 'middleware.express', description: 'expressInit' }), - expect.objectContaining({ op: 'middleware.express', description: 'corsMiddleware' }), - expect.objectContaining({ op: 'request_handler.express', description: '/testPath' }), - ], + transaction: event => { + expect(event.transaction).toBe('GET /testRequest'); + + const requestSpans = event.spans?.filter(span => span.op === 'http.client'); + expect(requestSpans).toHaveLength(1); + expect(requestSpans![0]?.description).toBe('GET http://example.com/pass'); + + const breadcrumbs = event.breadcrumbs?.filter(b => b.category === 'http'); + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs![0]?.data?.url).toEqual('http://example.com/pass'); }, }) .start(done); - runner.makeRequest('post', '/testPath'); + runner.makeRequest('get', '/testRequest'); }); }); }); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 62922b1b3921..090c0783507a 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,8 +1,10 @@ import type * as http from 'node:http'; +import type { RequestOptions } from 'node:http'; import type * as https from 'node:https'; import { VERSION } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { getRequestInfo } from '@opentelemetry/instrumentation-http'; import { addBreadcrumb, getClient, getIsolationScope, withIsolationScope } from '@sentry/core'; import type { SanitizedRequestData } from '@sentry/types'; import { @@ -12,12 +14,29 @@ import { stripUrlQueryAndFragment, } from '@sentry/utils'; import type { NodeClient } from '../../sdk/client'; +import { getRequestUrl } from '../../utils/getRequestUrl'; type Http = typeof http; type Https = typeof https; type SentryHttpInstrumentationOptions = InstrumentationConfig & { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ breadcrumbs?: boolean; + + /** + * Do not capture breadcrumbs for outgoing HTTP requests to URLs where the given callback returns `true`. + * For the scope of this instrumentation, this callback only controls breadcrumb creation. + * The same option can be passed to the top-level httpIntegration where it controls both, breadcrumb and + * span creation. + * + * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. + * @param request Contains the {@type RequestOptions} object used to make the outgoing request. + */ + ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; }; /** @@ -140,7 +159,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase http.ClientRequest, - ) => (...args: unknown[]) => http.ClientRequest { + ) => (options: URL | http.RequestOptions | string, ...args: unknown[]) => http.ClientRequest { // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; @@ -148,12 +167,34 @@ export class SentryHttpInstrumentation extends InstrumentationBase; request.prependListener('response', (response: http.IncomingMessage) => { - const breadcrumbs = instrumentation.getConfig().breadcrumbs; - const _breadcrumbs = typeof breadcrumbs === 'undefined' ? true : breadcrumbs; - if (_breadcrumbs) { + const _breadcrumbs = instrumentation.getConfig().breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + + const _ignoreOutgoingRequests = instrumentation.getConfig().ignoreOutgoingRequests; + const shouldCreateBreadcrumb = + typeof _ignoreOutgoingRequests === 'function' + ? !_ignoreOutgoingRequests(getRequestUrl(request), optionsParsed) + : true; + + if (breadCrumbsEnabled && shouldCreateBreadcrumb) { addRequestBreadcrumb(request, response); } }); diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index 9c8d13b58127..975503956f21 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -20,7 +20,7 @@ const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http interface HttpOptions { /** - * Whether breadcrumbs should be recorded for requests. + * Whether breadcrumbs should be recorded for outgoing requests. * Defaults to true */ breadcrumbs?: boolean; @@ -45,8 +45,8 @@ interface HttpOptions { ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; /** - * Do not capture spans or breadcrumbs for incoming HTTP requests to URLs where the given callback returns `true`. - * This controls both span & breadcrumb creation - spans will be non recording if tracing is disabled. + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * Spans will be non recording if tracing is disabled. * * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. * For example: `'/users/details?id=123'` @@ -82,12 +82,15 @@ interface HttpOptions { }; } -export const instrumentSentryHttp = generateInstrumentOnce<{ breadcrumbs?: boolean }>( - `${INTEGRATION_NAME}.sentry`, - options => { - return new SentryHttpInstrumentation({ breadcrumbs: options?.breadcrumbs }); - }, -); +export const instrumentSentryHttp = generateInstrumentOnce<{ + breadcrumbs?: HttpOptions['breadcrumbs']; + ignoreOutgoingRequests?: HttpOptions['ignoreOutgoingRequests']; +}>(`${INTEGRATION_NAME}.sentry`, options => { + return new SentryHttpInstrumentation({ + breadcrumbs: options?.breadcrumbs, + ignoreOutgoingRequests: options?.ignoreOutgoingRequests, + }); +}); export const instrumentOtelHttp = generateInstrumentOnce(INTEGRATION_NAME, config => { const instrumentation = new HttpInstrumentation(config);