Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
}
40 changes: 22 additions & 18 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,17 +11,17 @@ 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';
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';
Expand Down Expand Up @@ -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;
}
Comment on lines +110 to +112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: In warm serverless environments, stale normalizedRequest data from a previous request can be reused because the global default isolation scope is not reset between requests.
Severity: MEDIUM

Suggested Fix

The global default isolation scope should be cleared or reset at the beginning of each request in serverless environments. This ensures that buildContextWithSentryScopes starts with a clean scope and does not reuse stale data from previous invocations in the same warm worker.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nextjs/src/edge/index.ts#L110-L112

Potential issue: In warm serverless environments like Vercel Edge or Cloudflare Workers,
the module-level singleton from `getDefaultIsolationScope()` is not reset between
requests. This causes stale `normalizedRequest` data from a previous request to persist.
When a subsequent request is processed, the `beforeSampling` hook incorrectly finds
existing `normalizedRequest` data and exits early. As a result, the sampler uses the
stale data from the first request to make a sampling decision for the second request,
potentially leading to incorrect sampling.

Did we get this right? 👍 / 👎 to inform future reviews.


const normalizedRequest = getNormalizedRequestFromAttributes(spanAttributes);
if (normalizedRequest) {
isolationScope.setSDKProcessingMetadata({ normalizedRequest });
}
});

client?.on('spanStart', span => {
const spanAttributes = spanToJSON(span).data;
const rootSpan = getRootSpan(span);
Expand All @@ -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
Expand Down
33 changes: 3 additions & 30 deletions packages/nextjs/src/server/handleOnSpanStart.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading