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('cloudflare');
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
Expand Up @@ -21,6 +21,9 @@ test('Should create a transaction for middleware', async ({ request }) => {
expect(middlewareTransaction.contexts?.runtime?.name).toBe('node');
expect(middlewareTransaction.transaction_info?.source).toBe('route');

expect(middlewareTransaction.request?.method).toBe('GET');
expect(middlewareTransaction.request?.url).toContain('/api/endpoint-behind-middleware');

// Assert that isolation scope works properly
expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true);
expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
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;
}
35 changes: 17 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,20 @@ 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 normalizedRequest = getNormalizedRequestFromAttributes(spanAttributes);
if (normalizedRequest) {
getIsolationScope().setSDKProcessingMetadata({ normalizedRequest });
}
Comment on lines +109 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: The beforeSampling hook writes to the global isolation scope, but the sampler reads from the OTel context's scope. These may be different, causing the sampler to miss the written data.
Severity: MEDIUM

Suggested Fix

Ensure that the isolation scope used by the SentrySampler is the same instance as the one modified in the beforeSampling hook. This could involve synchronizing the global scope with the OTel context before sampling occurs or passing the correct scope object directly to the hook.

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#L109-L112

Potential issue: A potential scope mismatch exists between the `beforeSampling` hook and
the `SentrySampler`. The hook, located in `edge/index.ts`, writes the
`normalizedRequest` data to the global isolation scope using `getIsolationScope()`.
However, the sampler later retrieves this data from an isolation scope attached to the
OTel context via `getScopesFromContext(context)`. If the global scope and the OTel
context's scope are not synchronized at the time of sampling, the `normalizedRequest`
written by the hook will not be visible to the sampler, potentially leading to incorrect
sampling decisions.

});

client?.on('spanStart', span => {
const spanAttributes = spanToJSON(span).data;
const rootSpan = getRootSpan(span);
Expand All @@ -120,19 +131,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