Skip to content
Draft
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
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Page() {
return <h1>Page behind proxy</h1>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ export async function proxy(request: NextRequest) {

// See "Matching Paths" below to learn more
export const config = {
matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'],
matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/page-behind-proxy'],
};
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ test('Should create a span for middleware', async ({ request }) => {
expect(routeSpan.attributes['isolation_scope.has_proxy_marker']).toEqual({ value: false, type: 'boolean' });
});

for (const path of ['/api/endpoint-behind-middleware', '/page-behind-proxy']) {
test(`Should create one http.server segment for a request to ${path} behind the proxy`, async ({ request }) => {
test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span');

// Next.js runs the proxy in a `BaseServer.handleRequest` span of its own, before the one that renders the route.
// That first span has neither a route nor the middleware span as a child, so it must not become a second
// `http.server` segment.
const spansPromise = collectStreamedSpans(
'nextjs-16',
spans =>
spans.some(span => span.is_segment && span.name === 'middleware GET') &&
spans.some(span => span.is_segment && span.name === `GET ${path}`),
);

const response = await request.get(path);
expect(response.status()).toBe(200);

const segmentSpans = (await spansPromise).filter(span => span.is_segment);
expect(segmentSpans.map(span => span.name).sort()).toEqual([`GET ${path}`, 'middleware GET']);
expect(segmentSpans.filter(span => getSpanOp(span) === 'http.server').map(span => span.name)).toEqual([
`GET ${path}`,
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Span op asserted as string literal

Low Severity

The new segment assertion compares getSpanOp to the string 'http.server'. Testing conventions require span-op assertions to use the @sentry/conventions constant rather than a literal. Flagged because it is called out in the PR review rules.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit bcb8427. Configure here.

});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Loop registers scenarios instead of test.each

Low Severity

A for loop registers the same proxy-segment test for two paths. Testing conventions call for test.each when one case covers multiple scenarios, instead of generating tests in a loop. Flagged because it is called out in the PR review rules.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit bcb8427. Configure here.


// `withSentry` from `@sentry/nextjs/cloudflare` wraps the Worker entry, so middleware runs inside the request's
// `http.server` segment and its span is a child of it rather than a segment of its own.
test('Should create a span for middleware inside the request segment of the Worker', async ({ request }) => {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,16 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
callback: (spanScope: { scope: Scope; parentSpan: Span | undefined }) => void,
): () => void;

/**
* Register a callback right before a span is created, before `ignoreSpans` is checked. Listeners can change the
* name and the attributes of the span by mutating the passed `spanStartData` object.
* @returns {() => void} A function that, when executed, removes the registered callback.
*/
public on(
hook: 'beforeSpanStart',
callback: (spanStartData: { name: string | undefined; attributes: SpanAttributes }) => void,
): () => void;

/**
* Register a callback before span sampling runs. Receives a `samplingDecision` object argument with a `decision`
* property that can be used to make a sampling decision that will be enforced, before any span sampling runs.
Expand Down Expand Up @@ -1038,6 +1048,9 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
/** A hook that is called right before a span is created; listeners mutate the passed object. */
public emit(hook: 'prepareSpanScope', spanScope: { scope: Scope; parentSpan: Span | undefined }): void;

/** A hook that is called right before a span is created, before `ignoreSpans` is checked. */
public emit(hook: 'beforeSpanStart', spanStartData: { name: string | undefined; attributes: SpanAttributes }): void;

/** A hook that is called every time before a span is sampled. */
public emit(
hook: 'beforeSampling',
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export {
INTERNAL_setSegmentNameSourceIfSegment,
} from './utils/spanUtils';
export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope';
export {
_getIncomingRequestForScope as _INTERNAL_getIncomingRequestForScope,
_setIncomingRequestForScope as _INTERNAL_setIncomingRequestForScope,
} from './utils/incomingRequestOnScope';
export { parseSampleRate } from './utils/parseSampleRate';
export { applySdkMetadata } from './utils/sdkMetadata';
export { getTraceData } from './utils/traceData';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/integrations/http/server-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { patchRequestToCaptureBody } from './patch-request-to-capture-body';
import { getUrlFragment, getUrlQuery, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url';
import { recordRequestSession } from './record-request-session';
import { _setIncomingRequestForScope } from '../../utils/incomingRequestOnScope';
import { generateSpanId, generateTraceId } from '../../utils/propagationContext';
import { continueTrace, startSpanManual } from '../../tracing/trace';
import { getSpanStatusFromHttpCode, SPAN_STATUS_ERROR } from '../../tracing';
Expand Down Expand Up @@ -153,6 +154,7 @@ export function instrumentServer(options: HttpInstrumentationOptions, server: Ht

// Update the isolation scope, isolate this request
isolationScope.setSDKProcessingMetadata({ normalizedRequest, ipAddress });
_setIncomingRequestForScope(isolationScope, request);

// attempt to update the scope's `transactionName` based on the request
// URL. Ideally, framework instrumentations coming after the
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,12 @@ function createChildOrRootSpan({
}

const client = getClient();
// Copy the attributes, so listeners do not mutate an object the caller passed in
const spanStartData = { name: spanArguments.name, attributes: { ...spanArguments.attributes } };
client?.emit('beforeSpanStart', spanStartData);
spanArguments.name = spanStartData.name;
spanArguments.attributes = spanStartData.attributes;

if (_shouldIgnoreStreamedSpan(client, spanArguments)) {
if (!isTracingSuppressed(scope)) {
// if tracing is actively suppressed (Sentry.suppressTracing(...)),
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/utils/incomingRequestOnScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Scope } from '../scope';
import { derefWeakRef, makeWeakRef, type MaybeWeakRef } from './weakRef';

const SCOPE_INCOMING_REQUEST_FIELD = 'incomingRequest';

/**
* Set the incoming request (e.g. Node's `IncomingMessage`) that a request's isolation scope belongs to.
* NOTE: This should NOT be used directly, but is only used internally by the server instrumentation.
*/
export function _setIncomingRequestForScope(scope: Scope, request: object): void {
// Use WeakRef so a scope that outlives the request (e.g. captured on a span) does not keep it alive
scope.refs[SCOPE_INCOMING_REQUEST_FIELD] = makeWeakRef(request);
}

/**
* Get the incoming request that a request's isolation scope belongs to.
* NOTE: This should NOT be used directly, but is only used internally by framework SDKs.
*/
export function _getIncomingRequestForScope(scope: Scope): object | undefined {
return derefWeakRef(scope.refs[SCOPE_INCOMING_REQUEST_FIELD] as MaybeWeakRef<object> | undefined);
}
45 changes: 45 additions & 0 deletions packages/core/test/lib/tracing/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2717,6 +2717,51 @@ describe('ignoreSpans (core path, streaming)', () => {
});
});

it('ignores a span that a beforeSpanStart listener marks for ignoreSpans', () => {
const options = getDefaultTestClientOptions({
tracesSampleRate: 1,
traceLifecycle: 'stream',
ignoreSpans: [{ attributes: { 'test.ignore': true } }],
});
client = new TestClient(options);
setCurrentClient(client);
client.init();
const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent');
client.on('beforeSpanStart', ({ name, attributes }) => {
if (name === 'GET') {
attributes['test.ignore'] = true;
}
});

startSpan({ name: 'GET' }, span => {
expect(span).toBeInstanceOf(SentryNonRecordingSpan);
});
startSpan({ name: 'GET /users' }, span => {
expect(span).toBeInstanceOf(SentrySpan);
});

expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1);
expect(spyOnDroppedEvent).toHaveBeenCalledWith('ignored', 'span');
});

it('applies the changes of beforeSpanStart listeners without mutating the attributes passed in', () => {
const options = getDefaultTestClientOptions({ tracesSampleRate: 1 });
client = new TestClient(options);
setCurrentClient(client);
client.init();
client.on('beforeSpanStart', spanStartData => {
spanStartData.name = 'renamed';
spanStartData.attributes['test.added'] = 'yes';
});

const attributes = { 'test.original': 'yes' };
const span = startInactiveSpan({ name: 'original', attributes });

expect(spanToJSON(span).name).toBe('renamed');
expect(spanToJSON(span).attributes).toMatchObject({ 'test.original': 'yes', 'test.added': 'yes' });
expect(attributes).toEqual({ 'test.original': 'yes' });
});

it('returns SentryNonRecordingSpan for startInactiveSpan matching ignoreSpans', () => {
const options = getDefaultTestClientOptions({
tracesSampleRate: 1,
Expand Down
38 changes: 38 additions & 0 deletions packages/nextjs/src/server/serverSpanHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import {
import { MIDDLEWARE } from '@sentry/conventions/op';
import type { Client, Options, Span } from '@sentry/core';
import {
_INTERNAL_getIncomingRequestForScope,
getIsolationScope,
getRootSpan,
GLOBAL_OBJ,
hasSpanStreamingEnabled,
HTTP_SPAN_NAME_FALLBACK,
parseSemver,
spanToJSON,
} from '@sentry/core';
import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan';
Expand All @@ -36,6 +39,11 @@ import {
} from './vercelQueuesMonitoring';

const NEXTJS_ERROR_PAGE_ROUTES = ['/500', '/_error'];
const NEXT_REQUEST_META = Symbol.for('NextInternalRequestMeta');

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryNextJsVersion?: string;
};

export const NEXTJS_SERVER_IGNORE_SPANS: NonNullable<Options['ignoreSpans']> = [
// Static assets (matches `_next/static` anywhere in the name to handle custom basePath)
Expand Down Expand Up @@ -77,6 +85,12 @@ export function addNextjsServerSpanHooks(client: Client): void {
}
});

client.on('beforeSpanStart', ({ attributes }) => {
if (isProxyInvocationSpan(attributes)) {
attributes[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION] = true;
}
});

client.on('spanStart', span => handleOnSpanStart(span, client));

// Normalize name/op/source/status on the request root span at span end, before it is serialized into
Expand All @@ -103,6 +117,30 @@ export function addNextjsServerSpanHooks(client: Client): void {

type SpanAttributes = ReturnType<typeof spanToJSON>['attributes'];

/**
* Whether a span is the `BaseServer.handleRequest` span that Next.js starts only to run the proxy (middleware).
*
* Next.js handles a request behind a proxy with two `handleRequest` calls: one that only runs the proxy, and one that
* renders the route. Since Next.js 16.3 (vercel/next.js#95306), the proxy span is not a child of the first span anymore,
* so that span would end up as an empty `http.server` segment next to the one of the route. On older versions it holds
* the proxy span and becomes the `middleware` segment, so it must be kept there.
*/
function isProxyInvocationSpan(spanAttributes: Record<string, unknown>): boolean {
if (spanAttributes[ATTR_NEXT_SPAN_TYPE] !== 'BaseServer.handleRequest') {
return false;
}

const { major, minor } = parseSemver(globalWithInjectedValues._sentryNextJsVersion ?? '');
if (major === undefined || minor === undefined || major < 16 || (major === 16 && minor < 3)) {
return false;
}

// Next.js sets this flag on the request before the call that only runs the proxy, and resets it before rendering
const request: { [NEXT_REQUEST_META]?: { middlewareInvoke?: boolean } } | undefined =
_INTERNAL_getIncomingRequestForScope(getIsolationScope());
return request?.[NEXT_REQUEST_META]?.middlewareInvoke === true;
}

/**
* Hoists the parameterized route that a Next.js span carries in `next.route` up to the root span, as its name and
* `http.route`.
Expand Down
69 changes: 65 additions & 4 deletions packages/nextjs/test/server/serverSpanHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ import {
SENTRY_SEGMENT_NAME_SOURCE,
} from '@sentry/conventions/attributes';
import { MIDDLEWARE } from '@sentry/conventions/op';
import type { Client } from '@sentry/core';
import { addChildSpanToSpan, SentrySpan, spanToStaticSpanJSON } from '@sentry/core';
import { describe, expect, it } from 'vitest';
import type { Client, SpanAttributes } from '@sentry/core';
import {
_INTERNAL_setIncomingRequestForScope,
addChildSpanToSpan,
GLOBAL_OBJ,
SentrySpan,
shouldIgnoreSpan,
spanToStaticSpanJSON,
withIsolationScope,
} from '@sentry/core';
import { afterEach, describe, expect, it } from 'vitest';
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes';
import { handleOnSpanStart } from '../../src/server/serverSpanHooks';
import {
addNextjsServerSpanHooks,
handleOnSpanStart,
NEXTJS_SERVER_IGNORE_SPANS,
} from '../../src/server/serverSpanHooks';

const client = { getOptions: () => ({}) } as unknown as Client;

Expand Down Expand Up @@ -171,3 +183,52 @@ describe('handleOnSpanStart', () => {
expect(spanToStaticSpanJSON(rootSpan).description).toBe('GET /api/endpoint');
});
});

describe('addNextjsServerSpanHooks beforeSpanStart', () => {
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion?: string };

afterEach(() => {
delete globalWithInjectedValues._sentryNextJsVersion;
});

function isRequestSpanIgnored(nextVersion: string | undefined, middlewareInvoke: boolean | undefined): boolean {
globalWithInjectedValues._sentryNextJsVersion = nextVersion;

let beforeSpanStart: ((data: { name: string | undefined; attributes: SpanAttributes }) => void) | undefined;
addNextjsServerSpanHooks({
on: (hook: string, callback: typeof beforeSpanStart) => {
if (hook === 'beforeSpanStart') {
beforeSpanStart = callback;
}
},
getOptions: () => ({}),
} as unknown as Client);

const request = { [Symbol.for('NextInternalRequestMeta')]: { middlewareInvoke } };
const spanStartData = { name: 'GET', attributes: { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } };
withIsolationScope(isolationScope => {
_INTERNAL_setIncomingRequestForScope(isolationScope, request);
beforeSpanStart?.(spanStartData);
});
return shouldIgnoreSpan(
{ description: spanStartData.name, attributes: spanStartData.attributes },
NEXTJS_SERVER_IGNORE_SPANS,
);
}

it('ignores the request span that Next.js only starts to run the proxy', () => {
expect(isRequestSpanIgnored('16.3.0', true)).toBe(true);
});

it('keeps the request span that renders the route', () => {
expect(isRequestSpanIgnored('16.3.0', false)).toBe(false);
});

it('keeps the request span that runs the proxy before Next.js 16.3, since the proxy span is its child there', () => {
expect(isRequestSpanIgnored('16.2.4', true)).toBe(false);
});

it('keeps the request span that runs the proxy if the Next.js version is unknown', () => {
expect(isRequestSpanIgnored(undefined, true)).toBe(false);
});
});
Loading