Skip to content
Merged
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
41 changes: 41 additions & 0 deletions packages/nextjs/src/edge/enhanceMiddlewareRootSpan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { stripUrlQueryAndFragment } from '@sentry/core';
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';

export interface MutableMiddlewareRootSpan {
attributes: Record<string, unknown>;
getName(): string | undefined;
setName(name: string): void;
}

/**
* Normalizes the transaction name for the root span of a Next.js `Middleware.execute` request on the Edge runtime.
*
* Older Next.js versions append the full URL to the middleware span name (e.g. `middleware GET /foo?bar=1`),
* producing high-cardinality transaction names. We collapse the name to `middleware {METHOD}` when possible,
* and strip query/fragment otherwise.
*
* Called from two places that operate on different shapes of the same underlying root span:
* - Legacy mode: from `preprocessEvent`, adapted around a transaction `Event` whose `contexts.trace.data`
* holds the root span's attributes and whose `event.transaction` is the root span's name.
* - Streamed mode: from `processSegmentSpan`, adapted around a `StreamedSpanJSON` (the streamed
* counterpart of the legacy transaction root) directly.
*/
export function enhanceMiddlewareRootSpan(span: MutableMiddlewareRootSpan): void {
const { attributes } = span;

if (attributes[ATTR_NEXT_SPAN_TYPE] !== 'Middleware.execute') {
return;
}

const spanName = attributes[ATTR_NEXT_SPAN_NAME];
if (typeof spanName !== 'string' || !spanName || !span.getName()) {
return;
}

const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/);
if (match) {
span.setName(`middleware ${match[1]}`);
} else {
span.setName(stripUrlQueryAndFragment(spanName));
}
}
73 changes: 32 additions & 41 deletions packages/nextjs/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import { context } from '@opentelemetry/api';
import {
applySdkMetadata,
type EventProcessor,
getCapturedScopesOnSpan,
getCurrentScope,
getGlobalScope,
Expand All @@ -17,7 +16,6 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setCapturedScopesOnSpan,
spanToJSON,
stripUrlQueryAndFragment,
} from '@sentry/core';
import { getScopesFromContext } from '@sentry/opentelemetry';
import type { VercelEdgeOptions } from '@sentry/vercel-edge';
Expand All @@ -31,6 +29,7 @@ import { isBuild } from '../common/utils/isBuild';
import { flushSafelyWithTimeout, isCloudflareWaitUntilAvailable, waitUntil } from '../common/utils/responseEnd';
import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata';
import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration';
import { enhanceMiddlewareRootSpan } from './enhanceMiddlewareRootSpan';

export * from '@sentry/vercel-edge';
export * from '../common';
Expand Down Expand Up @@ -85,6 +84,12 @@ export function init(options: VercelEdgeOptions = {}): void {
...(isRunningOnCloudflare && { runtime: { name: 'cloudflare' } }),
};

const nextjsIgnoreSpans: NonNullable<VercelEdgeOptions['ignoreSpans']> = [
// (set in `dropMiddlewareTunnelRequests` during `spanStart`)
{ attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } },
];
opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans];

// Use appropriate SDK metadata based on the runtime environment
if (isRunningOnCloudflare) {
applySdkMetadata(opts, 'nextjs', ['nextjs', 'cloudflare']);
Expand Down Expand Up @@ -137,61 +142,47 @@ export function init(options: VercelEdgeOptions = {}): void {
// Use the preprocessEvent hook instead of an event processor, so that the users event processors receive the most
// up-to-date value, but also so that the logic that detects changes to the transaction names to set the source to
// "custom", doesn't trigger.
// This handles the legacy (non-streamed) path where the segment span is emitted as a transaction event;
// `enhanceMiddlewareRootSpan` is adapted to operate on the event's trace context, which is the segment span's data.
// Span streaming bypasses event processors entirely - see the `processSegmentSpan` hook below for that path.
client?.on('preprocessEvent', event => {
// The otel auto inference will clobber the transaction name because the span has an http.target
if (
event.type === 'transaction' &&
event.contexts?.trace?.data?.['next.span_type'] === 'Middleware.execute' &&
event.contexts?.trace?.data?.['next.span_name']
) {
if (event.transaction) {
// Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names.
// We want to remove the url from the name here.
const spanName = event.contexts.trace.data['next.span_name'];

if (typeof spanName === 'string') {
const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/);
if (match) {
const normalizedName = `middleware ${match[1]}`;
event.transaction = normalizedName;
} else {
event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']);
}
}
}
if (event.type === 'transaction' && event.contexts?.trace?.data) {
enhanceMiddlewareRootSpan({
attributes: event.contexts.trace.data,
getName: () => event.transaction,
setName: name => {
event.transaction = name;
},
});
}

setUrlProcessingMetadata(event);
});

// Streamed-span counterpart of the `preprocessEvent` hook above. Streamed segment spans never become
// transaction events, so the same enhancement has to be applied here directly on the span JSON.
client?.on('processSegmentSpan', span => {
const attributes = (span.attributes ??= {});
enhanceMiddlewareRootSpan({
attributes,
getName: () => span.name,
setName: name => {
span.name = name;
},
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Feat PR missing integration or E2E test

Low Severity

This is a feat PR that migrates edge event processors to span-first APIs, including a new ignoreSpans configuration and a new processSegmentSpan hook. The diff only includes unit tests (enhanceMiddlewareRootSpan.test.ts and edgeSdk.test.ts). Per the project review rules, feat PRs need at least one integration or E2E test to verify the end-to-end behavior of the new span-first flow, especially for the streamed-span path which is entirely new functionality.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 04e5262. Configure here.


client?.on('spanEnd', span => {
if (span === getRootSpan(span)) {
waitUntil(flushSafelyWithTimeout());
}
});

getGlobalScope().addEventProcessor(
Object.assign(
(event => {
// Filter transactions that we explicitly want to drop.
if (event.type === 'transaction') {
if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) {
return null;
}

return event;
} else {
return event;
}
}) satisfies EventProcessor,
{ id: 'NextLowQualityTransactionsFilter' },
),
);

try {
// @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js
if (process.turbopack) {
getGlobalScope().setTag('turbopack', true);
getGlobalScope().setAttribute('turbopack', true);
Comment thread
chargome marked this conversation as resolved.
}
} catch {
// Noop
Expand Down
110 changes: 110 additions & 0 deletions packages/nextjs/test/edge/enhanceMiddlewareRootSpan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest';
import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes';
import { enhanceMiddlewareRootSpan } from '../../src/edge/enhanceMiddlewareRootSpan';

function makeSpan(attributes: Record<string, unknown>, name?: string) {
let currentName = name;
return {
span: {
attributes,
getName: () => currentName,
setName: (n: string) => {
currentName = n;
},
},
getName: () => currentName,
};
}

describe('enhanceMiddlewareRootSpan', () => {
it('does nothing for spans that are not Middleware.execute', () => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' },
'GET /foo',
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('GET /foo');
});

it('does nothing when next.span_name is missing', () => {
const { span, getName } = makeSpan({ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute' }, 'middleware');

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('middleware');
});

it('does nothing when next.span_name is an empty string', () => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '' },
'middleware',
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('middleware');
});

it('does nothing when next.span_name is not a string', () => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 123 },
'middleware',
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('middleware');
});

it('does nothing when the current name is empty', () => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware GET /foo' },
undefined,
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBeUndefined();
});

it.each([
['middleware GET /foo', 'middleware GET'],
['middleware POST /api/protected?token=abc', 'middleware POST'],
['middleware DELETE /resources/[id]', 'middleware DELETE'],
['middleware HEAD /', 'middleware HEAD'],
])('collapses "%s" to "%s"', (spanName, expected) => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: spanName },
spanName,
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe(expected);
});

it('strips query and fragment from non-method-prefixed middleware names', () => {
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: '/api/foo?token=abc#section' },
'/api/foo?token=abc#section',
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('/api/foo');
});

it('does not collapse names that do not match the middleware-method prefix', () => {
// CONNECT and TRACE are not in the regex - they fall through to query/fragment stripping
const { span, getName } = makeSpan(
{ [ATTR_NEXT_SPAN_TYPE]: 'Middleware.execute', [ATTR_NEXT_SPAN_NAME]: 'middleware CONNECT /foo?bar=1' },
'middleware CONNECT /foo?bar=1',
);

enhanceMiddlewareRootSpan(span);

expect(getName()).toBe('middleware CONNECT /foo');
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing integration or E2E test for feat PR

Low Severity

This feat PR only includes unit tests (enhanceMiddlewareRootSpan.test.ts and edgeSdk.test.ts) but no integration or E2E test. Per project rules, feat PRs are expected to include at least one integration or E2E test. While this is largely a migration of existing behavior, an integration test verifying the end-to-end middleware span name normalization on the edge runtime (covering both legacy and streamed paths) would help catch regressions.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 7334e14. Configure here.

25 changes: 25 additions & 0 deletions packages/nextjs/test/edgeSdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Integration } from '@sentry/core';
import { GLOBAL_OBJ } from '@sentry/core';
import * as SentryVercelEdge from '@sentry/vercel-edge';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached';
import { init } from '../src/edge';

// normally this is set as part of the build process, so mock it here
Expand Down Expand Up @@ -74,6 +75,30 @@ describe('Edge init()', () => {
});
});

describe('ignoreSpans', () => {
function getIgnoreSpans(): NonNullable<SentryVercelEdge.VercelEdgeOptions['ignoreSpans']> {
const callArgs = vercelEdgeInit.mock.calls[0]?.[0] as SentryVercelEdge.VercelEdgeOptions;
return callArgs.ignoreSpans ?? [];
}

it('appends the TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION attribute filter', () => {
init({});
const patterns = getIgnoreSpans();

expect(patterns).toContainEqual({
attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true },
});
});

it('preserves user-provided ignoreSpans entries', () => {
init({ ignoreSpans: ['user-pattern', /custom-regex/] });
const patterns = getIgnoreSpans();

expect(patterns).toContain('user-pattern');
expect(patterns.some(p => p instanceof RegExp && p.source === 'custom-regex')).toBe(true);
});
});

describe('environment option', () => {
const originalEnv = process.env.SENTRY_ENVIRONMENT;

Expand Down
Loading