diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts index 0bc599f836f2..c674845c4234 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/server-components.test.ts @@ -1,5 +1,9 @@ import { expect, test } from '@playwright/test'; -import { collectSpanNamesUntilSegment, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +// Next.js emits these spans itself. The SDK attaches no op, description or function name to +// them, so asserting `undefined` pins that they stay untouched. +const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; test('Sends a span for a request to app router with URL', async ({ page }) => { const spansPromise = collectStreamedSpansUntilSegment( @@ -43,39 +47,103 @@ test('Sends a span for a request to app router with URL', async ({ page }) => { test('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-bun', 'GET /nested-layout'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-bun', 'GET /nested-layout'); await page.goto('/nested-layout'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-bun', 'GET /nested-layout/[dynamic]'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-bun', 'GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "[dynamic]"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout/[dynamic]"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ + ...nextjsSpan, + name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts index 98d242896465..7a34986062dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/server-components.test.ts @@ -1,5 +1,9 @@ import { expect, test } from '@playwright/test'; -import { collectSpanNamesUntilSegment, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +// Next.js emits these spans itself. The SDK attaches no op, description or function name to +// them, so asserting `undefined` pins that they stay untouched. +const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; // TODO: Server component tests need SDK adjustments for Cloudflare Workers test.skip('Sends a span for a request to app router with URL', async ({ page }) => { @@ -45,40 +49,104 @@ test.skip('Sends a span for a request to app router with URL', async ({ page }) test.skip('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout'); await page.goto('/nested-layout'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); // TODO: Server component span tests need SDK adjustments for Cloudflare Workers test.skip('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout/[dynamic]'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-cf-workers', 'GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "[dynamic]"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout/[dynamic]"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ + ...nextjsSpan, + name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts index 75973b85c105..2b8427fb2e78 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming/tests/server-components.test.ts @@ -1,7 +1,11 @@ import { expect, test } from '@playwright/test'; -import { collectSpanNamesUntilSegment, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +// Next.js emits these spans itself. The SDK attaches no op, description or function name to +// them, so asserting `undefined` pins that they stay untouched. +const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; + test('Sends a streamed span for a request to app router with URL', async ({ page }) => { test.skip(isDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); @@ -22,20 +26,58 @@ test('Will create streamed spans for every server component and metadata generat }) => { test.skip(isDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-streaming', 'GET /nested-layout'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-streaming', 'GET /nested-layout'); await page.goto('/nested-layout'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); + + // The route detail that the low-cardinality name no longer carries stays on attributes. + const pageSpan = fullSpans.find( + span => span.attributes['sentry.description']?.value === 'resolve page server component "/nested-layout"', + )!; + expect(pageSpan.attributes).toMatchObject({ + 'sentry.nextjs.ssr.function.type': { value: 'Page', type: 'string' }, + 'sentry.nextjs.ssr.function.route': { value: '/nested-layout', type: 'string' }, + 'http.route': { value: '/nested-layout', type: 'string' }, + }); }); test('Will create streamed spans for every server component and metadata generation functions when visiting a dynamic page', async ({ @@ -43,20 +85,56 @@ test('Will create streamed spans for every server component and metadata generat }) => { test.skip(isDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16-streaming', 'GET /nested-layout/[dynamic]'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16-streaming', 'GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanNames = await spanNamesPromise; - - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanNames).toContainEqual('start response'); + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); + + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "[dynamic]"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout/[dynamic]"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ + ...nextjsSpan, + name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts index bb3380a9e774..b271cfd1ac52 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -1,7 +1,11 @@ import { expect, test } from '@playwright/test'; -import { collectSpanNamesUntilSegment, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; import { isTurbopackDevMode } from './isDevMode'; +// Next.js emits these spans itself. The SDK attaches no op, description or function name to +// them, so asserting `undefined` pins that they stay untouched. +const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; + test('Sends a span for a request to app router with URL', async ({ page }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for nested dynamic routes in dev mode'); @@ -46,20 +50,48 @@ test('Sends a span for a request to app router with URL', async ({ page }) => { test('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16', 'GET /nested-layout'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /nested-layout'); await page.goto('/nested-layout'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ @@ -67,20 +99,56 @@ test('Will create spans for every server component and metadata generation funct }) => { test.skip(isTurbopackDevMode, 'Turbopack intermittently returns 404 for dynamic routes in dev mode'); - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-16', 'GET /nested-layout/[dynamic]'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanNames = await spanNamesPromise; - - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanNames).toContainEqual('start response'); + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); + + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "[dynamic]"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout/[dynamic]"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ + ...nextjsSpan, + name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts index fb438ba98825..049f47737043 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -1,45 +1,113 @@ import { expect, test } from '@playwright/test'; -import { collectSpanNamesUntilSegment } from '@sentry-internal/test-utils'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +// Next.js emits these spans itself. The SDK attaches no op, description or function name to +// them, so asserting `undefined` pins that they stay untouched. +const nextjsSpan = { op: undefined, description: undefined, codeFunctionName: undefined }; test('Will create spans for every server component and metadata generation functions when visiting a page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-app-dir', 'GET /nested-layout'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-app-dir', 'GET /nested-layout'); await page.goto('/nested-layout'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'generateMetadata /(nested-layout)/nested-layout/page' }); - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout"'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); test('Will create spans for every server component and metadata generation functions when visiting a dynamic page', async ({ page, }) => { - const spanNamesPromise = collectSpanNamesUntilSegment('nextjs-app-dir', 'GET /nested-layout/[dynamic]'); + const spansPromise = collectStreamedSpansUntilSegment('nextjs-app-dir', 'GET /nested-layout/[dynamic]'); await page.goto('/nested-layout/123'); - const spanNames = await spanNamesPromise; + const fullSpans = await spansPromise; + const spans = fullSpans.map(span => ({ + name: span.name, + op: getSpanOp(span), + description: span.attributes['sentry.description']?.value, + codeFunctionName: span.attributes['code.function.name']?.value, + })); - expect(spanNames).toContainEqual('render route (app) /nested-layout/[dynamic]'); - expect(spanNames).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'render route (app) /nested-layout/[dynamic]' }); + expect(spans).toContainEqual({ + ...nextjsSpan, + name: 'generateMetadata /(nested-layout)/nested-layout/[dynamic]/page', + }); - expect(spanNames).toContainEqual('resolve page components'); - expect(spanNames).toContainEqual('build component tree'); - expect(spanNames).toContainEqual('resolve root layout server component'); - expect(spanNames).toContainEqual('resolve layout server component "(nested-layout)"'); - expect(spanNames).toContainEqual('resolve layout server component "nested-layout"'); - expect(spanNames).toContainEqual('resolve layout server component "[dynamic]"'); - expect(spanNames).toContainEqual('resolve page server component "/nested-layout/[dynamic]"'); - expect(spanNames).toContainEqual('start response'); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'resolve page components' }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'build component tree' }); + // Server component spans: the name is the low-cardinality `code.function.name`, and the + // segment each one resolved for is on the description. + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve root layout server component', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "(nested-layout)"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "nested-layout"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Layout', + op: 'function', + description: 'resolve layout server component "[dynamic]"', + codeFunctionName: 'Layout', + }); + expect(spans).toContainEqual({ + name: 'Page', + op: 'function', + description: 'resolve page server component "/nested-layout/[dynamic]"', + codeFunctionName: 'Page', + }); + expect(spans).toContainEqual({ ...nextjsSpan, name: 'start response' }); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 89c473c98b2d..5467f376e916 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -70,11 +70,14 @@ test('Should set a "not_found" status on a server component span when notFound() // Page server component span should have the right name and attributes expect(spans).toContainEqual( expect.objectContaining({ - name: 'resolve page server component "/server-component/not-found"', + name: 'Page', attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.description': { value: 'resolve page server component "/server-component/not-found"', type: 'string' }, + 'code.function.name': { value: 'Page', type: 'string' }, 'sentry.nextjs.ssr.function.type': { value: 'Page', type: 'string' }, 'sentry.nextjs.ssr.function.route': { value: '/server-component/not-found', type: 'string' }, + 'http.route': { value: '/server-component/not-found', type: 'string' }, }), }), ); @@ -111,11 +114,14 @@ test('Should capture an error and spans for a app router page', async ({ page }) // The page server component span should have the right name and attributes expect(spans).toContainEqual( expect.objectContaining({ - name: 'resolve page server component "/server-component/faulty"', + name: 'Page', attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.description': { value: 'resolve page server component "/server-component/faulty"', type: 'string' }, + 'code.function.name': { value: 'Page', type: 'string' }, 'sentry.nextjs.ssr.function.type': { value: 'Page', type: 'string' }, 'sentry.nextjs.ssr.function.route': { value: '/server-component/faulty', type: 'string' }, + 'http.route': { value: '/server-component/faulty', type: 'string' }, }), }), ); diff --git a/packages/nextjs/src/common/utils/tracingUtils.ts b/packages/nextjs/src/common/utils/tracingUtils.ts index eb59229175a3..778c19ce0780 100644 --- a/packages/nextjs/src/common/utils/tracingUtils.ts +++ b/packages/nextjs/src/common/utils/tracingUtils.ts @@ -1,7 +1,14 @@ -import { HTTP_ROUTE, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + CODE_FUNCTION_NAME, + HTTP_ROUTE, + SENTRY_DESCRIPTION, + SENTRY_NEXTJS_SSR_FUNCTION_ROUTE, + SENTRY_NEXTJS_SSR_FUNCTION_TYPE, + SENTRY_OP, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import type { PropagationContext, RawAttributes, Span } from '@sentry/core'; -import { isObjectLike, Scope, INTERNAL_setSegmentNameSourceIfSegment } from '@sentry/core'; +import type { Client, PropagationContext, RawAttributes, Span } from '@sentry/core'; +import { isObjectLike, Scope, INTERNAL_setSegmentNameSourceIfSegment, hasSpanStreamingEnabled } from '@sentry/core'; import { ATTR_NEXT_SEGMENT, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; const commonPropagationContextMap = new WeakMap(); @@ -98,6 +105,7 @@ export function maybeEnhanceServerComponentSpanName( activeSpan: Span, spanAttributes: RawAttributes>, rootSpanAttributes: RawAttributes>, + client: Client, ): void { if (!isResolveSegmentSpan(spanAttributes)) { return; @@ -105,12 +113,21 @@ export function maybeEnhanceServerComponentSpanName( const segment = spanAttributes[ATTR_NEXT_SEGMENT] as string; const route = rootSpanAttributes[HTTP_ROUTE]; - const enhancedName = getEnhancedResolveSegmentSpanName({ segment, route: typeof route === 'string' ? route : '' }); - activeSpan.updateName(enhancedName); + + const enhancedName = segment === PAGE_SEGMENT ? 'Page' : 'Layout'; + const enhancedDescription = getEnhancedResolveSegmentSpanName({ + segment, + route: typeof route === 'string' ? route : '', + }); + + activeSpan.updateName(hasSpanStreamingEnabled(client) ? enhancedName : enhancedDescription); activeSpan.setAttributes({ - 'sentry.nextjs.ssr.function.type': segment === PAGE_SEGMENT ? 'Page' : 'Layout', - 'sentry.nextjs.ssr.function.route': route as string | undefined, + [SENTRY_NEXTJS_SSR_FUNCTION_TYPE]: segment === PAGE_SEGMENT ? 'Page' : 'Layout', + [SENTRY_NEXTJS_SSR_FUNCTION_ROUTE]: route as string | undefined, [SENTRY_OP]: FUNCTION, + [SENTRY_DESCRIPTION]: enhancedDescription, + [CODE_FUNCTION_NAME]: enhancedName, + [HTTP_ROUTE]: route as string | undefined, }); // Usually a child of the request root span, in which case this no-ops and the root keeps the name // source it got from `handleOnSpanStart` / `enhanceHandleRequestRootSpan`. diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index 9d2becd663a8..5a7f3ea59772 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -112,7 +112,7 @@ export function handleOnSpanStart(span: Span, client: Client): void { maybeForkIsolationScopeForRootSpan(span, spanAttributes); - maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes); + maybeEnhanceServerComponentSpanName(span, spanAttributes, rootSpanAttributes, client); // Enrich outgoing http.client spans targeting the Vercel Queues API (producer) maybeEnrichQueueProducerSpan(span); diff --git a/packages/nextjs/test/common/utils/tracingUtils.test.ts b/packages/nextjs/test/common/utils/tracingUtils.test.ts new file mode 100644 index 000000000000..8e897ad20ecd --- /dev/null +++ b/packages/nextjs/test/common/utils/tracingUtils.test.ts @@ -0,0 +1,127 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { Client, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ATTR_NEXT_SEGMENT, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../../src/common/nextSpanAttributes'; +import { maybeEnhanceServerComponentSpanName } from '../../../src/common/utils/tracingUtils'; + +function mockClient(traceLifecycle: 'stream' | 'static'): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle }), + } as unknown as Client); +} + +function mockSpan() { + const span = { + name: undefined as string | undefined, + attributes: {} as Record, + updateName(name: string) { + span.name = name; + }, + setAttributes(attributes: Record) { + Object.assign(span.attributes, attributes); + }, + setAttribute(key: string, value: unknown) { + span.attributes[key] = value; + }, + }; + return span; +} + +function resolveSegmentAttributes(segment: string) { + return { + [ATTR_NEXT_SPAN_TYPE]: 'NextNodeServer.getLayoutOrPageModule', + [ATTR_NEXT_SPAN_NAME]: 'resolve segment modules', + [ATTR_NEXT_SEGMENT]: segment, + }; +} + +// `null` means the root span carries no `http.route` yet. +function enhance(segment: string, route: string | null = '/nested-layout/[dynamic]') { + const span = mockSpan(); + maybeEnhanceServerComponentSpanName( + span as unknown as Span, + resolveSegmentAttributes(segment) as never, + (route === null ? {} : { [HTTP_ROUTE]: route }) as never, + SentryCore.getClient()!, + ); + return span; +} + +describe('maybeEnhanceServerComponentSpanName', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does nothing for spans that are not resolve segment spans', () => { + mockClient('stream'); + const span = mockSpan(); + + maybeEnhanceServerComponentSpanName( + span as unknown as Span, + { [ATTR_NEXT_SPAN_TYPE]: 'BaseServer.handleRequest' } as never, + {} as never, + SentryCore.getClient()!, + ); + + expect(span.name).toBeUndefined(); + expect(span.attributes).toEqual({}); + }); + + describe('with span streaming enabled', () => { + it.each([ + ['__PAGE__', 'Page', 'resolve page server component "/nested-layout/[dynamic]"'], + ['', 'Layout', 'resolve root layout server component'], + ['[dynamic]', 'Layout', 'resolve layout server component "[dynamic]"'], + ])('names the %s segment span %s', (segment, expectedName, expectedDescription) => { + mockClient('stream'); + + const span = enhance(segment); + + expect(span.name).toBe(expectedName); + expect(span.attributes).toMatchObject({ + 'sentry.description': expectedDescription, + 'code.function.name': expectedName, + 'sentry.op': 'function', + }); + }); + }); + + describe('with span streaming disabled', () => { + it.each([ + ['__PAGE__', 'resolve page server component "/nested-layout/[dynamic]"'], + ['', 'resolve root layout server component'], + ['[dynamic]', 'resolve layout server component "[dynamic]"'], + ])('keeps the descriptive name for the %s segment span', (segment, expectedName) => { + mockClient('static'); + + const span = enhance(segment); + + expect(span.name).toBe(expectedName); + // The description is set in both lifecycles, so it always matches what the static name was. + expect(span.attributes['sentry.description']).toBe(expectedName); + }); + }); + + it('keeps the route and the `Page`/`Layout` distinction on attributes', () => { + mockClient('stream'); + + expect(enhance('__PAGE__').attributes).toMatchObject({ + 'sentry.nextjs.ssr.function.type': 'Page', + 'sentry.nextjs.ssr.function.route': '/nested-layout/[dynamic]', + 'http.route': '/nested-layout/[dynamic]', + }); + expect(enhance('[dynamic]').attributes).toMatchObject({ + 'sentry.nextjs.ssr.function.type': 'Layout', + 'http.route': '/nested-layout/[dynamic]', + }); + }); + + it('falls back to an empty route when the root span has none', () => { + mockClient('static'); + const span = enhance('__PAGE__', null); + + expect(span.name).toBe('resolve page server component ""'); + expect(span.attributes['http.route']).toBeUndefined(); + }); +});