diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts index bb9db27b50d7..a491ccde0a91 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/middleware.ts @@ -1,11 +1,15 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; -export function middleware(request: NextRequest) { +export async function middleware(request: NextRequest) { if (request.headers.has('x-should-throw')) { throw new Error('Middleware Error'); } + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + return NextResponse.next(); } diff --git a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts index 7cf989236360..b7bacd9d4ce4 100644 --- a/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts +++ b/packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts @@ -45,3 +45,46 @@ test('Records exceptions happening in middleware', async ({ request }) => { expect(await errorEventPromise).toBeDefined(); }); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware' && + !!transactionEvent.spans?.find(span => span.op === 'http.client') + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.spans).toEqual( + expect.arrayContaining([ + { + data: { 'http.method': 'GET', 'http.response.status_code': 200, type: 'fetch', url: 'http://localhost:3030/' }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.wintercg_fetch', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + tags: { 'http.status_code': '200' }, + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ]), + ); + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'fetch', + data: { __span: expect.any(String), method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); +}); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 7c66d8f1fb7f..09005785d335 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -11,6 +11,7 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __rewriteFramesDistDir__?: string; + fetch: (...args: unknown[]) => unknown; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ diff --git a/packages/node/src/integrations/undici/index.ts b/packages/node/src/integrations/undici/index.ts index ff08d1df0f65..6ffec69d7d24 100644 --- a/packages/node/src/integrations/undici/index.ts +++ b/packages/node/src/integrations/undici/index.ts @@ -272,7 +272,10 @@ function setHeadersOnRequest( sentryTrace: string, sentryBaggageHeader: string | undefined, ): void { - if (request.__sentry_has_headers__) { + const headerLines = request.headers.split('\r\n'); + const hasSentryHeaders = headerLines.some(headerLine => headerLine.startsWith('sentry-trace:')); + + if (hasSentryHeaders) { return; } @@ -280,8 +283,6 @@ function setHeadersOnRequest( if (sentryBaggageHeader) { request.addHeader('baggage', sentryBaggageHeader); } - - request.__sentry_has_headers__ = true; } function createRequestSpan( diff --git a/packages/node/src/integrations/undici/types.ts b/packages/node/src/integrations/undici/types.ts index d885984671bf..231c49aebf1f 100644 --- a/packages/node/src/integrations/undici/types.ts +++ b/packages/node/src/integrations/undici/types.ts @@ -236,7 +236,6 @@ export interface UndiciResponse { export interface RequestWithSentry extends UndiciRequest { __sentry_span__?: Span; - __sentry_has_headers__?: boolean; } export interface RequestCreateMessage { diff --git a/packages/tracing-internal/src/node/integrations/express.ts b/packages/tracing-internal/src/node/integrations/express.ts index f7ff20fa9986..b8874ec6f27a 100644 --- a/packages/tracing-internal/src/node/integrations/express.ts +++ b/packages/tracing-internal/src/node/integrations/express.ts @@ -3,6 +3,7 @@ import type { Hub, Integration, PolymorphicRequest, Transaction } from '@sentry/ import { extractPathForTransaction, getNumberOfUrlSegments, + GLOBAL_OBJ, isRegExp, logger, stripUrlQueryAndFragment, @@ -485,7 +486,8 @@ function getLayerRoutePathInfo(layer: Layer): LayerRoutePathInfo { if (!lrp) { // parse node.js major version - const [major] = process.versions.node.split('.').map(Number); + // Next.js will complain if we directly use `proces.versions` here because of edge runtime. + const [major] = (GLOBAL_OBJ as unknown as NodeJS.Global).process.versions.node.split('.').map(Number); // allow call extractOriginalRoute only if node version support Regex d flag, node 16+ if (major >= 16) { diff --git a/packages/types/src/instrument.ts b/packages/types/src/instrument.ts index b6bf1132d7ab..d3581d694312 100644 --- a/packages/types/src/instrument.ts +++ b/packages/types/src/instrument.ts @@ -37,7 +37,7 @@ interface SentryFetchData { export interface HandlerDataFetch { args: any[]; - fetchData: SentryFetchData; + fetchData: SentryFetchData; // This data is among other things dumped directly onto the fetch breadcrumb data startTimestamp: number; endTimestamp?: number; // This is actually `Response` - Note: this type is not complete. Add to it if necessary. diff --git a/packages/utils/src/supports.ts b/packages/utils/src/supports.ts index ebaa633acd7b..2e555ee2efb5 100644 --- a/packages/utils/src/supports.ts +++ b/packages/utils/src/supports.ts @@ -4,6 +4,8 @@ import { getGlobalObject } from './worldwide'; // eslint-disable-next-line deprecation/deprecation const WINDOW = getGlobalObject(); +declare const EdgeRuntime: string | undefined; + export { supportsHistory } from './vendor/supportsHistory'; /** @@ -89,6 +91,10 @@ export function isNativeFetch(func: Function): boolean { * @returns true if `window.fetch` is natively implemented, false otherwise */ export function supportsNativeFetch(): boolean { + if (typeof EdgeRuntime === 'string') { + return true; + } + if (!supportsFetch()) { return false; } diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 0f945d2016a1..080a6cd9804a 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -25,7 +25,8 @@ "dependencies": { "@sentry/core": "7.80.1", "@sentry/types": "7.80.1", - "@sentry/utils": "7.80.1" + "@sentry/utils": "7.80.1", + "@sentry-internal/tracing": "7.80.1" }, "devDependencies": { "@edge-runtime/jest-environment": "2.2.3", diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 8b8247639ae5..4f9b81345203 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -70,8 +70,11 @@ export { defaultIntegrations, init } from './sdk'; import { Integrations as CoreIntegrations } from '@sentry/core'; +import { WinterCGFetch } from './integrations/wintercg-fetch'; + const INTEGRATIONS = { ...CoreIntegrations, + ...WinterCGFetch, }; export { INTEGRATIONS as Integrations }; diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts new file mode 100644 index 000000000000..3ecffad83b33 --- /dev/null +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -0,0 +1,163 @@ +import { instrumentFetchRequest } from '@sentry-internal/tracing'; +import { getCurrentHub, isSentryRequestUrl } from '@sentry/core'; +import type { FetchBreadcrumbData, FetchBreadcrumbHint, HandlerDataFetch, Integration, Span } from '@sentry/types'; +import { addInstrumentationHandler, LRUMap, stringMatchesSomePattern } from '@sentry/utils'; + +export interface Options { + /** + * Whether breadcrumbs should be recorded for requests + * Defaults to true + */ + breadcrumbs: boolean; + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; +} + +/** + * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. + */ +export class WinterCGFetch implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'WinterCGFetch'; + + /** + * @inheritDoc + */ + public name: string = WinterCGFetch.id; + + private readonly _options: Options; + + private readonly _createSpanUrlMap: LRUMap = new LRUMap(100); + private readonly _headersUrlMap: LRUMap = new LRUMap(100); + + public constructor(_options: Partial = {}) { + this._options = { + breadcrumbs: _options.breadcrumbs === undefined ? true : _options.breadcrumbs, + shouldCreateSpanForRequest: _options.shouldCreateSpanForRequest, + }; + } + + /** + * @inheritDoc + */ + public setupOnce(): void { + const spans: Record = {}; + + addInstrumentationHandler('fetch', (handlerData: HandlerDataFetch) => { + const hub = getCurrentHub(); + if (!hub.getIntegration(WinterCGFetch)) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, hub)) { + return; + } + + instrumentFetchRequest( + handlerData, + this._shouldCreateSpan.bind(this), + this._shouldAttachTraceData.bind(this), + spans, + 'auto.http.wintercg_fetch', + ); + + if (this._options.breadcrumbs) { + createBreadcrumb(handlerData); + } + }); + } + + /** Decides whether to attach trace data to the outgoing fetch request */ + private _shouldAttachTraceData(url: string): boolean { + const hub = getCurrentHub(); + const client = hub.getClient(); + + if (!client) { + return false; + } + + const clientOptions = client.getOptions(); + + 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; + } + + /** 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; + } +} + +function createBreadcrumb(handlerData: HandlerDataFetch): void { + const { startTimestamp, endTimestamp } = handlerData; + + // We only capture complete fetch requests + if (!endTimestamp) { + return; + } + + if (handlerData.error) { + const data = handlerData.fetchData; + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + + getCurrentHub().addBreadcrumb( + { + category: 'fetch', + data, + level: 'error', + type: 'http', + }, + hint, + ); + } else { + const data: FetchBreadcrumbData = { + ...handlerData.fetchData, + status_code: handlerData.response && handlerData.response.status, + }; + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response: handlerData.response, + startTimestamp, + endTimestamp, + }; + getCurrentHub().addBreadcrumb( + { + category: 'fetch', + data, + type: 'http', + }, + hint, + ); + } +} diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 417c55bf809e..f49115452a5d 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -3,6 +3,7 @@ import { createStackParser, GLOBAL_OBJ, nodeStackLineParser, stackParserFromStac import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import { VercelEdgeClient } from './client'; +import { WinterCGFetch } from './integrations/wintercg-fetch'; import { makeEdgeTransport } from './transports'; import type { VercelEdgeClientOptions, VercelEdgeOptions } from './types'; import { getVercelEnv } from './utils/vercel'; @@ -17,6 +18,7 @@ export const defaultIntegrations = [ new CoreIntegrations.InboundFilters(), new CoreIntegrations.FunctionToString(), new CoreIntegrations.LinkedErrors(), + new WinterCGFetch(), ]; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts new file mode 100644 index 000000000000..699fb4891257 --- /dev/null +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -0,0 +1,192 @@ +import * as internalTracing from '@sentry-internal/tracing'; +import * as sentryCore from '@sentry/core'; +import type { HandlerDataFetch, Integration, IntegrationClass } from '@sentry/types'; +import * as sentryUtils from '@sentry/utils'; +import { createStackParser } from '@sentry/utils'; + +import { VercelEdgeClient } from '../src/index'; +import { WinterCGFetch } from '../src/integrations/wintercg-fetch'; + +class FakeHub extends sentryCore.Hub { + getIntegration(integration: IntegrationClass): T | null { + return new integration(); + } +} + +const fakeHubInstance = new FakeHub( + new VercelEdgeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableTracing: true, + tracesSampleRate: 1, + integrations: [], + transport: () => ({ + send: () => Promise.resolve(undefined), + flush: () => Promise.resolve(true), + }), + tracePropagationTargets: ['http://my-website.com/'], + stackParser: createStackParser(), + }), +); + +jest.spyOn(sentryCore, 'getCurrentHub').mockImplementation(() => fakeHubInstance); + +const addInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addInstrumentationHandler'); +const instrumentFetchRequestSpy = jest.spyOn(internalTracing, 'instrumentFetchRequest'); +const addBreadcrumbSpy = jest.spyOn(fakeHubInstance, 'addBreadcrumb'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('WinterCGFetch instrumentation', () => { + it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { + const integration = new WinterCGFetch(); + addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + integration.setupOnce(); + + const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = + addInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerType).toBe('fetch'); + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + 'auto.http.wintercg_fetch', + ); + + const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]; + + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); + expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); + }); + + it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { + const integration = new WinterCGFetch(); + addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + integration.setupOnce(); + + const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = + addInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerType).toBe('fetch'); + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('should properly apply the `shouldCreateSpanForRequest` option', () => { + const integration = new WinterCGFetch({ + shouldCreateSpanForRequest(url) { + return url === 'http://only-acceptable-url.com/'; + }, + }); + addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + integration.setupOnce(); + + const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = + addInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerType).toBe('fetch'); + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]; + + expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); + }); + + it('should create a breadcrumb for an outgoing request', () => { + const integration = new WinterCGFetch(); + addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + integration.setupOnce(); + + const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = + addInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerType).toBe('fetch'); + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startTimestamp = Date.now(); + const endTimestamp = Date.now() + 100; + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, + startTimestamp, + endTimestamp, + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(addBreadcrumbSpy).toBeCalledWith( + { + category: 'fetch', + data: { method: 'POST', status_code: 201, url: 'http://my-website.com/' }, + type: 'http', + }, + { + endTimestamp, + input: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' }, + startTimestamp, + }, + ); + }); + + it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { + const integration = new WinterCGFetch({ + breadcrumbs: false, + }); + addInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + + integration.setupOnce(); + + const [fetchInstrumentationHandlerType, fetchInstrumentationHandlerCallback] = + addInstrumentationHandlerSpy.mock.calls[0]; + expect(fetchInstrumentationHandlerType).toBe('fetch'); + expect(fetchInstrumentationHandlerCallback).toBeDefined(); + + const startTimestamp = Date.now(); + const endTimestamp = Date.now() + 100; + + const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, + startTimestamp, + endTimestamp, + }; + fetchInstrumentationHandlerCallback(startHandlerData); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); +});