Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
13 changes: 12 additions & 1 deletion dev-packages/e2e-tests/test-applications/nextjs-16/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"test:build": "pnpm install && pnpm build",
"test:build-webpack": "pnpm install && pnpm build-webpack",
"test:build-canary": "pnpm install && pnpm add next@canary && pnpm build",
"test:build-latest": "pnpm install && pnpm add next@latest && pnpm build",
"test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack",
"test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack",
"test:assert": "pnpm test:prod && pnpm test:dev",
"test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack"
Expand All @@ -25,7 +27,7 @@
"@sentry/core": "latest || *",
"ai": "^3.0.0",
"import-in-the-middle": "^1",
"next": "16.0.0-beta.0",
"next": "16.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^7",
Expand All @@ -50,6 +52,15 @@
"build-command": "pnpm test:build-webpack",
"label": "nextjs-16 (webpack)",
"assert-command": "pnpm test:assert-webpack"
},
{
"build-command": "pnpm test:build-latest-webpack",
"label": "nextjs-16 (latest, webpack)",
"assert-command": "pnpm test:assert-webpack"
},
{
"build-command": "pnpm test:build-latest",
"label": "nextjs-16 (latest, turbopack)"
}
],
"optionalVariants": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
import { isDevMode } from './isDevMode';

test('Should create a transaction for middleware', async ({ request }) => {
const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
Expand All @@ -13,8 +14,8 @@ test('Should create a transaction for middleware', async ({ request }) => {

expect(middlewareTransaction.contexts?.trace?.status).toBe('ok');
expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware');
expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge');
expect(middlewareTransaction.transaction_info?.source).toBe('url');
expect(middlewareTransaction.contexts?.runtime?.name).toBe('node');
expect(middlewareTransaction.transaction_info?.source).toBe('route');

// Assert that isolation scope works properly
expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true);
Expand All @@ -36,27 +37,29 @@ test('Faulty middlewares', async ({ request }) => {

await test.step('should record transactions', async () => {
const middlewareTransaction = await middlewareTransactionPromise;
expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error');
expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error');
expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware');
expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge');
expect(middlewareTransaction.transaction_info?.source).toBe('url');
expect(middlewareTransaction.contexts?.runtime?.name).toBe('node');
expect(middlewareTransaction.transaction_info?.source).toBe('route');
});

await test.step('should record exceptions', async () => {
const errorEvent = await errorEventPromise;

// Assert that isolation scope works properly
expect(errorEvent.tags?.['my-isolated-tag']).toBe(true);
expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
expect([
'middleware GET', // non-otel webpack versions
'/middleware', // middleware file
'/proxy', // proxy file
]).toContain(errorEvent.transaction);
});
// TODO: proxy errors currently not reported via onRequestError
// await test.step('should record exceptions', async () => {
// const errorEvent = await errorEventPromise;

// // Assert that isolation scope works properly
// expect(errorEvent.tags?.['my-isolated-tag']).toBe(true);
// expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
// expect([
// 'middleware GET', // non-otel webpack versions
// '/middleware', // middleware file
// '/proxy', // proxy file
// ]).toContain(errorEvent.transaction);
// });
});

test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => {
test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm');
const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
return (
transactionEvent?.transaction === 'middleware GET' &&
Expand All @@ -74,18 +77,26 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru
expect.arrayContaining([
{
data: {
'http.method': 'GET',
'http.request.method': 'GET',
'http.request.method_original': 'GET',
'http.response.status_code': 200,
type: 'fetch',
url: 'http://localhost:3030/',
'http.url': 'http://localhost:3030/',
'server.address': 'localhost:3030',
'network.peer.address': '::1',
'network.peer.port': 3030,
'otel.kind': 'CLIENT',
'sentry.op': 'http.client',
'sentry.origin': 'auto.http.wintercg_fetch',
'sentry.origin': 'auto.http.otel.node_fetch',
'server.address': 'localhost',
'server.port': 3030,
url: 'http://localhost:3030/',
'url.full': 'http://localhost:3030/',
'url.path': '/',
'url.query': '',
'url.scheme': 'http',
'user_agent.original': 'node',
},
description: 'GET http://localhost:3030/',
op: 'http.client',
origin: 'auto.http.wintercg_fetch',
origin: 'auto.http.otel.node_fetch',
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
span_id: expect.stringMatching(/[a-f0-9]{16}/),
start_timestamp: expect.any(Number),
Expand All @@ -95,11 +106,12 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru
},
]),
);

expect(middlewareTransaction.breadcrumbs).toEqual(
expect.arrayContaining([
{
category: 'fetch',
data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' },
category: 'http',
data: { 'http.method': 'GET', status_code: 200, url: 'http://localhost:3030/' },
timestamp: expect.any(Number),
type: 'http',
},
Expand Down
3 changes: 3 additions & 0 deletions packages/nextjs/src/common/nextSpanAttributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ATTR_NEXT_SPAN_TYPE = 'next.span_type';
export const ATTR_NEXT_SPAN_NAME = 'next.span_name';
export const ATTR_NEXT_ROUTE = 'next.route';
39 changes: 30 additions & 9 deletions packages/nextjs/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { getScopesFromContext } from '@sentry/opentelemetry';
import { DEBUG_BUILD } from '../common/debug-build';
import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
import { getVercelEnv } from '../common/getVercelEnv';
import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes';
import {
TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL,
TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL,
Expand Down Expand Up @@ -169,25 +170,35 @@ export function init(options: NodeOptions): NodeClient | undefined {

// What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted
// by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute.
if (typeof spanAttributes?.['next.route'] === 'string') {
if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') {
const rootSpanAttributes = spanToJSON(rootSpan).data;
// Only hoist the http.route attribute if the transaction doesn't already have it
if (
// eslint-disable-next-line deprecation/deprecation
(rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) &&
!rootSpanAttributes?.[ATTR_HTTP_ROUTE]
) {
const route = spanAttributes['next.route'].replace(/\/route$/, '');
const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, '');
rootSpan.updateName(route);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, route);
// Preserving the original attribute despite internally not depending on it
rootSpan.setAttribute('next.route', route);
rootSpan.setAttribute(ATTR_NEXT_ROUTE, route);
}
}

if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') {
const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME];
if (typeof middlewareName === 'string') {
rootSpan.updateName(middlewareName);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, middlewareName);
rootSpan.setAttribute(ATTR_NEXT_SPAN_NAME, middlewareName);
}
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto');
}

// We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans
// with patterns (e.g. http.server spans) that will produce confusing data.
if (spanAttributes?.['next.span_type'] !== undefined) {
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) {
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto');
}

Expand All @@ -197,7 +208,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
}

// We want to fork the isolation scope for incoming requests
if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) {
if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) {
const scopes = getCapturedScopesOnSpan(span);

const isolationScope = (scopes.isolationScope || getIsolationScope()).clone();
Expand Down Expand Up @@ -320,7 +331,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
// Enhance route handler transactions
if (
event.type === 'transaction' &&
event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest'
event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest'
) {
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
event.contexts.trace.op = 'http.server';
Expand All @@ -333,21 +344,31 @@ export function init(options: NodeOptions): NodeClient | undefined {
const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD];
// eslint-disable-next-line deprecation/deprecation
const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET];
const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route'];
const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE];
const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME];

if (typeof method === 'string' && typeof route === 'string') {
if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) {
const cleanRoute = route.replace(/\/route$/, '');
event.transaction = `${method} ${cleanRoute}`;
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
// Preserve next.route in case it did not get hoisted
event.contexts.trace.data['next.route'] = cleanRoute;
event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute;
}

// backfill transaction name for pages that would otherwise contain unparameterized routes
if (event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL] && event.transaction !== 'GET /_app') {
event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`;
}

const middlewareMatch =
typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/);

if (middlewareMatch) {
const normalizedName = `middleware ${middlewareMatch[1]}`;
event.transaction = normalizedName;
event.contexts.trace.op = 'http.server.middleware';
}

// Next.js overrides transaction names for page loads that throw an error
// but we want to keep the original target name
if (event.transaction === 'GET /_error' && target) {
Expand Down
Loading