diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts index 4e12ee74604b..64734157204f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/sentry.edge.config.ts @@ -4,7 +4,16 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1.0, + tracesSampler: samplingContext => { + if (samplingContext.attributes?.['next.span_type'] === 'Middleware.execute') { + // Only keep the middleware transaction when `normalizedRequest` was available at sampling time. + // Test times out and fails when transaction is dropped + const { normalizedRequest } = samplingContext; + return Boolean(normalizedRequest?.url && normalizedRequest?.method); + } + + return 1.0; + }, dataCollection: { userInfo: true }, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts index f769874a3d34..6545cb3372a5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/middleware.test.ts @@ -2,6 +2,21 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +// TODO: Skipped until the Cloudflare Workers edge middleware setup emits middleware transactions reliably. +test.skip('tracesSampler receives normalizedRequest for edge middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + await request.get('/api/endpoint-behind-middleware'); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware'); + expect(middlewareTransaction.request?.method).toBe('GET'); +}); + // TODO: Middleware tests need SDK adjustments for Cloudflare Workers edge runtime test.skip('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-16-cf-workers', async transactionEvent => { diff --git a/packages/nextjs/src/common/utils/forkIsolationScopeForRootSpan.ts b/packages/nextjs/src/common/utils/forkIsolationScopeForRootSpan.ts new file mode 100644 index 000000000000..53330ea9328f --- /dev/null +++ b/packages/nextjs/src/common/utils/forkIsolationScopeForRootSpan.ts @@ -0,0 +1,38 @@ +import { context } from '@opentelemetry/api'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + getCapturedScopesOnSpan, + getCurrentScope, + getIsolationScope, + getRootSpan, + setCapturedScopesOnSpan, +} from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; +import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; + +/** + * Forks the isolation scope for `BaseServer.handleRequest` / `Middleware.execute` root spans so that request-scoped + * data (e.g. `normalizedRequest`) stays isolated per request. + */ +export function maybeForkIsolationScopeForRootSpan(span: Span, spanAttributes: SpanAttributes | undefined): void { + const spanType = spanAttributes?.[ATTR_NEXT_SPAN_TYPE]; + if (spanType !== 'BaseServer.handleRequest' && spanType !== 'Middleware.execute') { + return; + } + + if (span !== getRootSpan(span)) { + return; + } + + const scopes = getCapturedScopesOnSpan(span); + + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + + const currentScopesPointer = getScopesFromContext(context.active()); + if (currentScopesPointer) { + currentScopesPointer.isolationScope = isolationScope; + } + + setCapturedScopesOnSpan(span, scope, isolationScope); +} diff --git a/packages/nextjs/src/common/utils/getNormalizedRequestFromAttributes.ts b/packages/nextjs/src/common/utils/getNormalizedRequestFromAttributes.ts new file mode 100644 index 000000000000..0f7d62d26422 --- /dev/null +++ b/packages/nextjs/src/common/utils/getNormalizedRequestFromAttributes.ts @@ -0,0 +1,48 @@ +import { + HTTP_METHOD, + HTTP_REQUEST_METHOD, + HTTP_TARGET, + HTTP_URL, + URL_FULL, + URL_PATH, + URL_QUERY, +} from '@sentry/conventions/attributes'; +import type { RequestEventData, SpanAttributes } from '@sentry/core'; + +/** + * Builds a partial `normalizedRequest` from OTel HTTP span attributes. + * Only method, URL, and query string can be derived — headers are not available as span attributes. + */ +export function getNormalizedRequestFromAttributes(attributes: SpanAttributes): RequestEventData | undefined { + // eslint-disable-next-line typescript/no-deprecated + const method = attributes[HTTP_REQUEST_METHOD] || attributes[HTTP_METHOD]; + + // eslint-disable-next-line typescript/no-deprecated + const url = attributes[URL_FULL] || attributes[HTTP_URL] || attributes[URL_PATH] || attributes[HTTP_TARGET]; + + if (typeof method !== 'string' && typeof url !== 'string') { + return undefined; + } + + const normalizedRequest: RequestEventData = {}; + + if (typeof method === 'string') { + normalizedRequest.method = method; + } + + if (typeof url === 'string') { + normalizedRequest.url = url; + + const queryFromAttribute = attributes[URL_QUERY]; + if (typeof queryFromAttribute === 'string') { + normalizedRequest.query_string = queryFromAttribute; + } else { + const queryIndex = url.indexOf('?'); + if (queryIndex !== -1) { + normalizedRequest.query_string = url.slice(queryIndex + 1); + } + } + } + + return normalizedRequest; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 4025f43dc777..a27fbfdaa5e4 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,11 +1,8 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { context } from '@opentelemetry/api'; import { applySdkMetadata, - getCapturedScopesOnSpan, - getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, @@ -14,10 +11,8 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - setCapturedScopesOnSpan, spanToJSON, } from '@sentry/core'; -import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -25,6 +20,8 @@ import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; +import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -101,6 +98,25 @@ export function init(options: VercelEdgeOptions = {}): void { const client = vercelEdgeInit(opts); + // Next.js's OTel instrumentation samples root spans before the Sentry middleware wrapper can set + // `normalizedRequest` on the isolation scope. Seed it from span attributes so `tracesSampler` has access. + client?.on('beforeSampling', ({ spanAttributes }) => { + const spanType = spanAttributes[ATTR_NEXT_SPAN_TYPE]; + if (spanType !== 'Middleware.execute' && spanType !== 'BaseServer.handleRequest') { + return; + } + + const isolationScope = getIsolationScope(); + if (isolationScope.getScopeData().sdkProcessingMetadata?.normalizedRequest) { + return; + } + + const normalizedRequest = getNormalizedRequestFromAttributes(spanAttributes); + if (normalizedRequest) { + isolationScope.setSDKProcessingMetadata({ normalizedRequest }); + } + }); + client?.on('spanStart', span => { const spanAttributes = spanToJSON(span).data; const rootSpan = getRootSpan(span); @@ -120,19 +136,7 @@ export function init(options: VercelEdgeOptions = {}): void { } // We want to fork the isolation scope for incoming requests - if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { - const scopes = getCapturedScopesOnSpan(span); - - const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); - const scope = scopes.scope || getCurrentScope(); - - const currentScopesPointer = getScopesFromContext(context.active()); - if (currentScopesPointer) { - currentScopesPointer.isolationScope = isolationScope; - } - - setCapturedScopesOnSpan(span, scope, isolationScope); - } + maybeForkIsolationScopeForRootSpan(span, spanAttributes); if (isRootSpan) { // todo: check if we can set request headers for edge on sdkProcessingMetadata diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index a59124a3a244..e5d056bb5f98 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -1,19 +1,10 @@ -import { context } from '@opentelemetry/api'; import { HTTP_METHOD, HTTP_REQUEST_METHOD, HTTP_ROUTE } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; -import { - getCapturedScopesOnSpan, - getCurrentScope, - getIsolationScope, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - setCapturedScopesOnSpan, - spanToJSON, -} from '@sentry/core'; -import { getScopesFromContext } from '@sentry/opentelemetry'; +import { getIsolationScope, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { maybeEnhanceServerComponentSpanName } from '../common/utils/tracingUtils'; import { maybeStartCronCheckIn } from './vercelCronsMonitoring'; import { maybeEnrichQueueConsumerSpan, maybeEnrichQueueProducerSpan } from './vercelQueuesMonitoring'; @@ -84,25 +75,7 @@ export function handleOnSpanStart(span: Span): void { addHeadersAsAttributes(headers, rootSpan); } - // We want to fork the isolation scope for incoming requests. Root `Middleware.execute` spans need the same - // treatment since Next.js 16.3.0-canary.79 - if ( - (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' || - spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') && - isRootSpan - ) { - const scopes = getCapturedScopesOnSpan(span); - - const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); - const scope = scopes.scope || getCurrentScope(); - - const currentScopesPointer = getScopesFromContext(context.active()); - if (currentScopesPointer) { - currentScopesPointer.isolationScope = isolationScope; - } - - setCapturedScopesOnSpan(span, scope, isolationScope); - } + maybeForkIsolationScopeForRootSpan(span, spanAttributes); maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes); diff --git a/packages/nextjs/test/common/utils/getNormalizedRequestFromAttributes.test.ts b/packages/nextjs/test/common/utils/getNormalizedRequestFromAttributes.test.ts new file mode 100644 index 000000000000..68038f02b20b --- /dev/null +++ b/packages/nextjs/test/common/utils/getNormalizedRequestFromAttributes.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { getNormalizedRequestFromAttributes } from '../../../src/common/utils/getNormalizedRequestFromAttributes'; + +describe('getNormalizedRequestFromAttributes', () => { + it('builds a request from `http.method` and `http.target` (edge middleware sample-time attributes)', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.method': 'GET', + 'http.target': '/api/endpoint-behind-middleware?query=123', + }); + + expect(normalizedRequest).toEqual({ + method: 'GET', + url: '/api/endpoint-behind-middleware?query=123', + query_string: 'query=123', + }); + }); + + it('prefers the new `http.request.method` and `url.full` attributes', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.request.method': 'POST', + 'http.method': 'GET', + 'url.full': 'https://example.com/foo?a=1', + 'http.target': '/foo?a=1', + }); + + expect(normalizedRequest).toEqual({ + method: 'POST', + url: 'https://example.com/foo?a=1', + query_string: 'a=1', + }); + }); + + it('prefers the `url.query` attribute over parsing the url', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.method': 'GET', + 'url.full': 'https://example.com/foo?a=1&b=2', + 'url.query': 'a=1&b=2', + }); + + expect(normalizedRequest).toEqual({ + method: 'GET', + url: 'https://example.com/foo?a=1&b=2', + query_string: 'a=1&b=2', + }); + }); + + it('omits `query_string` when there is no query', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.method': 'GET', + 'http.target': '/foo', + }); + + expect(normalizedRequest).toEqual({ + method: 'GET', + url: '/foo', + }); + }); + + it('builds a request when only the method is available', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.method': 'GET', + }); + + expect(normalizedRequest).toEqual({ method: 'GET' }); + }); + + it('builds a request when only the url is available', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.target': '/foo?a=1', + }); + + expect(normalizedRequest).toEqual({ url: '/foo?a=1', query_string: 'a=1' }); + }); + + it('falls back to `url.path` when `url.full` and `http.url` are absent', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.request.method': 'GET', + 'url.path': '/api/resource', + 'url.query': 'page=2', + }); + + expect(normalizedRequest).toEqual({ + method: 'GET', + url: '/api/resource', + query_string: 'page=2', + }); + }); + + it('prefers `url.full` over `url.path`', () => { + const normalizedRequest = getNormalizedRequestFromAttributes({ + 'http.request.method': 'GET', + 'url.full': 'https://example.com/api/resource?page=2', + 'url.path': '/api/resource', + }); + + expect(normalizedRequest).toEqual({ + method: 'GET', + url: 'https://example.com/api/resource?page=2', + query_string: 'page=2', + }); + }); + + it('returns undefined when neither method nor url is present', () => { + expect(getNormalizedRequestFromAttributes({})).toBeUndefined(); + expect(getNormalizedRequestFromAttributes({ 'next.span_type': 'Middleware.execute' })).toBeUndefined(); + }); +}); diff --git a/packages/nextjs/test/edge/beforeSampling.test.ts b/packages/nextjs/test/edge/beforeSampling.test.ts new file mode 100644 index 000000000000..e9c3d7f85f08 --- /dev/null +++ b/packages/nextjs/test/edge/beforeSampling.test.ts @@ -0,0 +1,122 @@ +import type { RequestEventData, SpanAttributes } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core'; +import type * as VercelEdgeModule from '@sentry/vercel-edge'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; + +// normally this is set as part of the build process, so mock it here +(GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir: string })._sentryRewriteFramesDistDir = '.next'; + +type BeforeSamplingHandler = (data: { spanAttributes: SpanAttributes }) => void; + +let beforeSamplingHandler: BeforeSamplingHandler | undefined; + +vi.mock('@sentry/vercel-edge', async () => { + const actual = (await vi.importActual('@sentry/vercel-edge')) as typeof VercelEdgeModule; + return { + ...actual, + init: vi.fn(() => { + return { + // Capture the `beforeSampling` handler so we can invoke it directly in tests + on: (hook: string, cb: unknown) => { + if (hook === 'beforeSampling') { + beforeSamplingHandler = cb as BeforeSamplingHandler; + } + }, + getOptions: () => ({}), + }; + }), + }; +}); + +// Import after the mock is set up +const { init } = await import('../../src/edge'); + +function getNormalizedRequest(): RequestEventData | undefined { + return getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; +} + +describe('edge beforeSampling handler', () => { + beforeEach(() => { + beforeSamplingHandler = undefined; + init({}); + delete getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + }); + + afterEach(() => { + delete getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest; + vi.clearAllMocks(); + }); + + it('registers a beforeSampling handler', () => { + expect(beforeSamplingHandler).toBeTypeOf('function'); + }); + + it('seeds normalizedRequest for Middleware.execute root spans', () => { + beforeSamplingHandler!({ + spanAttributes: { + [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', + 'http.method': 'GET', + 'http.target': '/api/endpoint-behind-middleware?query=123', + }, + }); + + expect(getNormalizedRequest()).toEqual({ + method: 'GET', + url: '/api/endpoint-behind-middleware?query=123', + query_string: 'query=123', + }); + }); + + it('seeds normalizedRequest for BaseServer.handleRequest root spans', () => { + beforeSamplingHandler!({ + spanAttributes: { + [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', + 'http.method': 'POST', + 'http.target': '/foo', + }, + }); + + expect(getNormalizedRequest()).toEqual({ + method: 'POST', + url: '/foo', + }); + }); + + it('does not override an existing normalizedRequest', () => { + const existing = { method: 'GET', url: 'https://example.com/full?a=1', headers: { host: 'example.com' } }; + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: existing }); + + beforeSamplingHandler!({ + spanAttributes: { + [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', + 'http.method': 'GET', + 'http.target': '/foo', + }, + }); + + expect(getNormalizedRequest()).toEqual(existing); + }); + + it('is a no-op for non-request span types', () => { + beforeSamplingHandler!({ + spanAttributes: { + [ATTR_NEXT_SPAN_TYPE]: 'Render.getServerSideProps', + 'http.method': 'GET', + 'http.target': '/foo', + }, + }); + + expect(getNormalizedRequest()).toBeUndefined(); + }); + + it('is a no-op when there are no usable HTTP attributes', () => { + beforeSamplingHandler!({ + spanAttributes: { + [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', + }, + }); + + expect(getNormalizedRequest()).toBeUndefined(); + }); +});