diff --git a/packages/vinext/src/server/app-metadata-tracing.ts b/packages/vinext/src/server/app-metadata-tracing.ts new file mode 100644 index 000000000..795053309 --- /dev/null +++ b/packages/vinext/src/server/app-metadata-tracing.ts @@ -0,0 +1,22 @@ +import { frameworkTracer } from "./tracer.js"; + +type AppMetadataModuleType = "forbidden" | "layout" | "not-found" | "page" | "unauthorized"; + +export function createAppMetadataModuleRoute( + routeSegments: readonly string[], + moduleType: AppMetadataModuleType, +): string { + return `/${[...routeSegments.filter((segment) => !segment.startsWith("@")), moduleType].join("/")}`; +} + +export function traceGenerateMetadata(moduleRoute: string, callback: () => T): T { + return frameworkTracer.trace(createGenerateMetadataSpanDescriptor(moduleRoute), callback); +} + +export function createGenerateMetadataSpanDescriptor(moduleRoute: string) { + return { + attributes: { "next.page": moduleRoute }, + name: `generateMetadata ${moduleRoute}`, + type: "ResolveMetadata.generateMetadata", + } as const; +} diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 6d5b9bbd6..5f9f44cc8 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -625,6 +625,11 @@ export async function renderAppPageHttpAccessFallback( discardAppPageRenderState(); } - const pageBuildResult = await buildCurrentPageElement(); - if (pageBuildResult.response) { - return pageBuildResult.response; - } - const navigationParams = resolveAppPageNavigationParams( route, options.params, options.cleanPathname, interceptResult.interceptOpts, ); - options.setNavigationContext({ - pathname: options.displayPathname ?? options.cleanPathname, - searchParams: pageSearchParams, - params: navigationParams, - }); - const layoutClassifications = getEffectiveLayoutClassifications( route, options.debugClassification, @@ -1174,7 +1163,16 @@ async function dispatchAppPageInner( createRscOnErrorHandler(pathname, routePath, overrides) { return options.createRscOnErrorHandler(pathname, routePath, overrides); }, - element: pageBuildResult.element, + async prepareElement() { + const pageBuildResult = await buildCurrentPageElement(); + if (pageBuildResult.response) return { response: pageBuildResult.response }; + options.setNavigationContext({ + pathname: options.displayPathname ?? options.cleanPathname, + searchParams: pageSearchParams, + params: navigationParams, + }); + return { element: pageBuildResult.element }; + }, clientReuseManifest: options.clientReuseManifest, getDraftModeCookieHeader, getFontLinks: options.getFontLinks, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index d85607fda..89ed5704d 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -338,6 +338,12 @@ export async function buildPageElements< ...(opts?.interceptNotFound ? { notFoundModule: opts.interceptNotFound, + notFoundModuleRouteSegments: ( + opts.interceptNotFoundBranchSegments ?? + opts.interceptSourcePageSegments ?? + route.routeSegments ?? + [] + ).slice(0, opts.interceptNotFoundTreePosition ?? 0), notFoundParams: resolveAppPageBranchParams( opts.interceptNotFoundBranchSegments ?? opts.interceptBranchSegments ?? @@ -426,6 +432,8 @@ export async function buildPageElements< return { boundaryModule, boundaryParams, + boundaryRouteSegments: (route.routeSegments ?? []).slice(0, boundaryTreePosition ?? 0), + errorConvention: "not-found" as const, layoutModules: route.layouts, layoutTreePositions: route.layoutTreePositions, parallelBranches: activeParallelRouteHeadInputs, diff --git a/packages/vinext/src/server/app-page-head.ts b/packages/vinext/src/server/app-page-head.ts index cfad2d09a..c68887e46 100644 --- a/packages/vinext/src/server/app-page-head.ts +++ b/packages/vinext/src/server/app-page-head.ts @@ -13,6 +13,7 @@ import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; import type { ThenableParamsObserver } from "vinext/shims/thenable-params"; import type { AppPageParams } from "./app-page-boundary.js"; import { tagAppPageMetadataError } from "./app-page-execution.js"; +import { createAppMetadataModuleRoute, traceGenerateMetadata } from "./app-metadata-tracing.js"; import { resolveAppPageBranchParams, resolveAppPageSegmentParams } from "./app-page-params.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; @@ -29,10 +30,13 @@ import type { MetadataFileRoute } from "./metadata-routes.js"; * and Next.js test/e2e/app-dir/metadata-streaming. */ async function resolveModuleMetadata( + moduleRoute: string, ...args: Parameters ): Promise { try { - return await _resolveModuleMetadata(...args); + return await (typeof args[0].generateMetadata === "function" + ? traceGenerateMetadata(moduleRoute, () => _resolveModuleMetadata(...args)) + : _resolveModuleMetadata(...args)); } catch (error) { throw tagAppPageMetadataError(error); } @@ -54,6 +58,7 @@ export type OrderedAppPageMetadataSource = { head: AppPageHeadParallelRoute; notFoundModule?: TModule | null; + notFoundModuleRouteSegments?: readonly string[] | null; notFoundParams?: AppPageParams | null; ownerTreePosition: number; }; @@ -184,6 +192,7 @@ export function resolveActiveParallelRouteHeadInputs[] { const inputs = Object.entries(options.slots ?? {}).map(([slotKey, slot]) => { const ownerTreePosition = options.layoutTreePositions?.[slot.layoutIndex ?? 0] ?? 0; + const ownerRouteSegments = options.routeSegments.slice(0, ownerTreePosition); const ownerParams = resolveAppPageSegmentParams( options.routeSegments, ownerTreePosition, @@ -209,6 +218,16 @@ export function resolveActiveParallelRouteHeadInputs 0 ? { moduleRoutePrefixSegments: ownerRouteSegments } : {}), + ...(slot.routeSegments ? {} : { moduleRouteSegments }), + routeSegments: slotRouteSegments, }, - ...(slot.notFound ? { notFoundModule: slot.notFound, notFoundParams } : {}), + ...(slot.notFound + ? { + notFoundModule: slot.notFound, + notFoundModuleRouteSegments: [ + ...ownerRouteSegments, + ...moduleRouteSegments.slice(0, slot.notFoundTreePosition ?? 0), + ], + notFoundParams, + } + : {}), ownerTreePosition, }; }); @@ -414,6 +450,7 @@ async function resolveLayoutMetadata( params, ); const metadataPromise = resolveModuleMetadata( + createAppMetadataModuleRoute(routeSegments.slice(0, layoutInput.treePosition), "layout"), layoutInput.module, layoutParams, undefined, @@ -505,6 +542,8 @@ async function resolveParallelRouteMetadata( ): Promise { const params = parallelRoute.params ?? fallbackParams; const routeSegments = parallelRoute.routeSegments ?? fallbackRouteSegments; + const moduleRoutePrefixSegments = parallelRoute.moduleRoutePrefixSegments ?? []; + const moduleRouteSegments = parallelRoute.moduleRouteSegments ?? routeSegments; const metadataResults: (Metadata | null)[] = []; const metadataSources: AppPageHeadSource[] = []; let accumulatedMetadata = parent; @@ -517,6 +556,13 @@ async function resolveParallelRouteMetadata( layoutParams[index] ?? resolveParallelLayoutParams(routeSegments, layoutTreePositions[index] ?? 0, params); const layoutMetadata = await resolveModuleMetadata( + createAppMetadataModuleRoute( + [ + ...moduleRoutePrefixSegments, + ...moduleRouteSegments.slice(0, layoutTreePositions[index] ?? 0), + ], + "layout", + ), layoutModule, currentLayoutParams, undefined, @@ -537,6 +583,7 @@ async function resolveParallelRouteMetadata( if (parallelRoute.pageModule) { const pageMetadata = await resolveModuleMetadata( + createAppMetadataModuleRoute([...moduleRoutePrefixSegments, ...moduleRouteSegments], "page"), parallelRoute.pageModule, params, pageSearchParams, @@ -622,6 +669,7 @@ export function resolveOrderedAppPageMetadata entries.length > 0 ? mergeMetadataEntries(entries) : {}, ); const metadataPromise = resolveModuleMetadata( + source.moduleRoute, source.module, source.params, source.searchParams, @@ -704,6 +752,7 @@ function prepareAppPageHeadInner( void pageParentPromise.catch(() => null); const pageMetadataPromise = options.pageModule ? resolveModuleMetadata( + createAppMetadataModuleRoute(routeSegments, "page"), options.pageModule, options.params, pageSearchParams, diff --git a/packages/vinext/src/server/app-page-http-access-fallback-metadata.ts b/packages/vinext/src/server/app-page-http-access-fallback-metadata.ts index 1b2770e2b..8507c4a4f 100644 --- a/packages/vinext/src/server/app-page-http-access-fallback-metadata.ts +++ b/packages/vinext/src/server/app-page-http-access-fallback-metadata.ts @@ -12,6 +12,7 @@ import { type ApplyAppPageFileBasedMetadata, type OrderedAppPageMetadataSource, } from "./app-page-head.js"; +import { createAppMetadataModuleRoute } from "./app-metadata-tracing.js"; import { resolveAppPageBranchParams, resolveAppPageSegmentParams } from "./app-page-params.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; @@ -19,8 +20,10 @@ type HttpAccessFallbackMetadataPlanOptions[] | null; @@ -70,6 +73,10 @@ function createHttpAccessFallbackPlan( source: { includeWhenEmpty: true, module: layoutModule, + moduleRoute: createAppMetadataModuleRoute( + routeSegments.slice(0, treePosition), + options.errorConvention, + ), params: resolveAppPageSegmentParams(routeSegments, treePosition, options.params), routeSegments: routeSegments.slice(0, treePosition), }, @@ -78,7 +85,7 @@ function createHttpAccessFallbackPlan( let activeBoundaryModule = options.boundaryModule; let activeBoundaryParams = options.boundaryParams; - let activeBoundaryRouteSegments = routeSegments; + let activeBoundaryRouteSegments = options.boundaryRouteSegments ?? []; const appendFallbackLeaf = () => { if (fallbackLeafMode === "final") { plan.push({ kind: "fallback-leaf" }); @@ -90,6 +97,10 @@ function createHttpAccessFallbackPlan( source: { includeWhenEmpty: true, module: activeBoundaryModule, + moduleRoute: createAppMetadataModuleRoute( + activeBoundaryRouteSegments, + options.errorConvention, + ), params: activeBoundaryParams, routeSegments: activeBoundaryRouteSegments, }, @@ -111,6 +122,8 @@ function createHttpAccessFallbackPlan( const parallelRoute = branch.head; const parallelParams = parallelRoute.params ?? options.params; const parallelRouteSegments = parallelRoute.routeSegments ?? routeSegments; + const parallelModuleRoutePrefix = parallelRoute.moduleRoutePrefixSegments ?? []; + const parallelModuleRouteSegments = parallelRoute.moduleRouteSegments ?? parallelRouteSegments; const layoutModules = [ ...(parallelRoute.layoutModules ?? []), parallelRoute.layoutModule, @@ -124,6 +137,13 @@ function createHttpAccessFallbackPlan( source: { includeWhenEmpty: true, module: layoutModule, + moduleRoute: createAppMetadataModuleRoute( + [ + ...parallelModuleRoutePrefix, + ...parallelModuleRouteSegments.slice(0, layoutTreePositions[index] ?? 0), + ], + options.errorConvention, + ), params: layoutParams[index] ?? resolveAppPageBranchParams( @@ -138,7 +158,10 @@ function createHttpAccessFallbackPlan( if (options.branchNotFoundConventions !== false && branch.notFoundModule) { activeBoundaryModule = branch.notFoundModule; activeBoundaryParams = branch.notFoundParams ?? parallelParams; - activeBoundaryRouteSegments = parallelRouteSegments; + activeBoundaryRouteSegments = branch.notFoundModuleRouteSegments ?? [ + ...parallelModuleRoutePrefix, + ...parallelModuleRouteSegments, + ]; } appendFallbackLeaf(); } @@ -150,6 +173,10 @@ function createHttpAccessFallbackPlan( { includeWhenEmpty: true, module: activeBoundaryModule, + moduleRoute: createAppMetadataModuleRoute( + activeBoundaryRouteSegments, + options.errorConvention, + ), params: activeBoundaryParams, routeSegments: activeBoundaryRouteSegments, }, diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index bbff55c62..5664ed711 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -103,7 +103,13 @@ type AppPageRequestCacheLife = { stale?: number; }; -type RenderAppPageLifecycleOptions = { +type AppPageRenderableElement = ReactNode | Readonly>; + +type PreparedAppPageElement = + | { element: AppPageRenderableElement; response?: never } + | { element?: never; response: Response }; + +type RenderAppPageLifecycleOptionsBase = { basePath?: string; bypassInterceptionContextCache?: boolean; /** @@ -212,10 +218,19 @@ type RenderAppPageLifecycleOptions = { // Per-layout observation tracker. Constructed in dispatch, consumed by the // skip transport planner to reject layouts that are unsafe for static reuse. layoutParamAccess?: AppLayoutParamAccessTracker; - element: ReactNode | Readonly>; classification?: LayoutClassificationOptions | null; }; +type RenderAppPageLifecycleOptions = RenderAppPageLifecycleOptionsBase & + ( + | { element: AppPageRenderableElement; prepareElement?: never } + | { element?: never; prepareElement: () => Promise } + ); + +type ResolvedRenderAppPageLifecycleOptions = RenderAppPageLifecycleOptionsBase & { + element: AppPageRenderableElement; +}; + function buildResponseTiming( options: Pick & { compileEnd?: number; @@ -642,7 +657,10 @@ function wrapRscResponseForDevErrorReporting( export async function renderAppPageLifecycle( options: RenderAppPageLifecycleOptions, ): Promise { - if (options.isRscRequest) return renderAppPageLifecycleImpl(options); + if (options.isRscRequest) { + const prepared = await prepareAppPageElement(options); + return prepared instanceof Response ? prepared : renderAppPageLifecycleImpl(prepared); + } const operation = options.traceOperation ?? (options.isPrerender ? "prerender" : "render"); let resolveResponse!: (response: Response) => void; @@ -654,9 +672,14 @@ export async function renderAppPageLifecycle( }); const tracedRender = traceAppPageRender(options.routePattern, operation, async (renderSpan) => { try { + const prepared = await prepareAppPageElement(options); + if (prepared instanceof Response) { + resolveResponse(prepared); + return; + } const response = await renderAppPageLifecycleImpl( { - ...options, + ...prepared, onRenderComplete(completion) { renderCompletion = completion; void completion.catch(() => {}); @@ -676,8 +699,16 @@ export async function renderAppPageLifecycle( return responsePromise; } -async function renderAppPageLifecycleImpl( +async function prepareAppPageElement( options: RenderAppPageLifecycleOptions, +): Promise { + if (!options.prepareElement) return options; + const prepared = await options.prepareElement(); + return prepared.response ?? { ...options, element: prepared.element }; +} + +async function renderAppPageLifecycleImpl( + options: ResolvedRenderAppPageLifecycleOptions, renderSpan?: FrameworkSpan, ): Promise { // Request dynamic state is consumptive, but both cache finalization and the diff --git a/tests/app-metadata-tracing.test.ts b/tests/app-metadata-tracing.test.ts new file mode 100644 index 000000000..720e6d059 --- /dev/null +++ b/tests/app-metadata-tracing.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + createAppMetadataModuleRoute, + createGenerateMetadataSpanDescriptor, +} from "../packages/vinext/src/server/app-metadata-tracing.js"; +import { + resolveActiveParallelRouteHeadInputs, + resolveAppPageHead, +} from "../packages/vinext/src/server/app-page-head.js"; +import { registerFrameworkTracingIntegration } from "../packages/vinext/src/server/tracer.js"; +import type { ResolvedFrameworkSpanDescriptor } from "../packages/vinext/src/server/framework-tracer.js"; + +const recordedDescriptors: ResolvedFrameworkSpanDescriptor[] = []; +registerFrameworkTracingIntegration({ + id: "app-metadata-tracing-test", + enterSpan(descriptor, callback) { + recordedDescriptors.push(descriptor); + return callback({ setAttribute() {} }); + }, +}); + +describe("App metadata tracing", () => { + // Ported from Next.js: test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts + it("matches the stable Next.js generateMetadata span descriptor", () => { + expect(createGenerateMetadataSpanDescriptor("/app/[param]/page")).toEqual({ + attributes: { + "next.page": "/app/[param]/page", + }, + name: "generateMetadata /app/[param]/page", + type: "ResolveMetadata.generateMetadata", + }); + }); + + it.each([ + { moduleType: "layout" as const, route: "/layout", segments: [] }, + { + moduleType: "layout" as const, + route: "/(group)/[param]/layout", + segments: ["(group)", "[param]"], + }, + { + moduleType: "page" as const, + route: "/app/[param]/page", + segments: ["app", "[param]"], + }, + { + moduleType: "not-found" as const, + route: "/app/[param]/not-found", + segments: ["app", "[param]"], + }, + { moduleType: "forbidden" as const, route: "/admin/forbidden", segments: ["admin"] }, + { + moduleType: "unauthorized" as const, + route: "/account/unauthorized", + segments: ["account"], + }, + { + moduleType: "page" as const, + route: "/[locale]/(.)photos/[photo]/page", + segments: ["[locale]", "@modal", "(.)photos", "[photo]"], + }, + ])("creates the $route module route", ({ moduleType, route, segments }) => { + expect(createAppMetadataModuleRoute(segments, moduleType)).toBe(route); + }); + + it("retains the owner prefix for nested parallel route metadata spans", async () => { + recordedDescriptors.length = 0; + const generateMetadata = async () => null; + const [input] = resolveActiveParallelRouteHeadInputs({ + layoutTreePositions: [0, 1], + params: {}, + routeSegments: ["dashboard", "settings"], + slots: { + sidebar: { + layout: { generateMetadata }, + layoutIndex: 1, + page: { generateMetadata }, + routeSegments: ["members"], + }, + }, + }); + + await resolveAppPageHead({ + layoutModules: [], + metadataRoutes: [], + parallelRoutes: [input.head], + params: {}, + routePath: "/dashboard/settings", + routeSegments: ["dashboard", "settings"], + }); + + expect( + recordedDescriptors + .filter(({ type }) => type === "ResolveMetadata.generateMetadata") + .map(({ attributes }) => attributes["next.page"]), + ).toEqual(["/dashboard/layout", "/dashboard/members/page"]); + }); + + it("uses the owner route for an inactive nested slot layout span", async () => { + recordedDescriptors.length = 0; + const [input] = resolveActiveParallelRouteHeadInputs({ + layoutTreePositions: [0, 1], + params: {}, + routeSegments: ["dashboard", "settings"], + slots: { + sidebar: { + layout: { generateMetadata: async () => null }, + layoutIndex: 1, + }, + }, + }); + expect(input.head.routeSegments).toEqual(["dashboard", "settings"]); + + await resolveAppPageHead({ + layoutModules: [], + metadataRoutes: [], + parallelRoutes: [input.head], + params: {}, + routePath: "/dashboard/settings", + routeSegments: ["dashboard", "settings"], + }); + + expect( + recordedDescriptors + .filter(({ type }) => type === "ResolveMetadata.generateMetadata") + .map(({ attributes }) => attributes["next.page"]), + ).toEqual(["/dashboard/layout"]); + }); +}); diff --git a/tests/app-page-head.test.ts b/tests/app-page-head.test.ts index e9d37f1a3..bdd7aed4f 100644 --- a/tests/app-page-head.test.ts +++ b/tests/app-page-head.test.ts @@ -759,6 +759,7 @@ describe("app page head resolution", () => { routeSegments: ["[locale]", "@modal", "(.)photos", "[photo]", "[comment]"], }, notFoundModule: interceptNotFound, + notFoundModuleRouteSegments: ["[locale]", "@modal"], notFoundParams: { locale: "en", photo: "42", comment: "7" }, ownerTreePosition: 1, }, diff --git a/tests/app-page-http-access-fallback-metadata.test.ts b/tests/app-page-http-access-fallback-metadata.test.ts index 31bcbde54..5baf34ef7 100644 --- a/tests/app-page-http-access-fallback-metadata.test.ts +++ b/tests/app-page-http-access-fallback-metadata.test.ts @@ -6,6 +6,32 @@ import { } from "../packages/vinext/src/server/app-page-http-access-fallback-metadata.js"; describe("HTTP-access fallback metadata planning", () => { + it.each([ + { convention: "not-found" as const, expected: "/docs/not-found" }, + { convention: "forbidden" as const, expected: "/docs/forbidden" }, + { convention: "unauthorized" as const, expected: "/docs/unauthorized" }, + ])("uses the $convention convention and module owner route", ({ convention, expected }) => { + const rootLayout = {}; + const nestedLayout = {}; + const boundary = {}; + const plan = createHttpAccessFallbackMetadataPlan({ + boundaryModule: boundary, + boundaryParams: {}, + boundaryRouteSegments: ["docs"], + errorConvention: convention, + layoutModules: [rootLayout, nestedLayout], + layoutTreePositions: [0, 1], + params: {}, + routeSegments: ["docs", "missing"], + }); + + expect(plan.map(({ moduleRoute }) => moduleRoute)).toEqual([ + `/${convention}`, + `/docs/${convention}`, + expected, + ]); + }); + it("places the fallback convention at every active leaf in owner order", () => { const rootLayout = {}; const nestedLayout = {}; @@ -17,6 +43,7 @@ describe("HTTP-access fallback metadata planning", () => { const plan = createHttpAccessFallbackMetadataPlan({ boundaryModule: boundary, boundaryParams: { locale: "en" }, + errorConvention: "not-found", layoutModules: [rootLayout, nestedLayout], layoutTreePositions: [0, 1], parallelBranches: [ @@ -84,6 +111,7 @@ describe("HTTP-access fallback metadata planning", () => { const viewport = await resolveHttpAccessFallbackViewport>({ boundaryModule: boundary, boundaryParams: { locale: "en" }, + errorConvention: "not-found", layoutModules: [], parallelBranches: [ { @@ -119,6 +147,7 @@ describe("HTTP-access fallback metadata planning", () => { const plan = createHttpAccessFallbackMetadataPlan({ boundaryModule: boundary, boundaryParams: {}, + errorConvention: "not-found", layoutModules: [rootLayout], layoutTreePositions: [0], parallelBranches: [ @@ -175,6 +204,7 @@ describe("HTTP-access fallback metadata planning", () => { const metadataPromise = resolveHttpAccessFallbackMetadata>({ boundaryModule: boundary, boundaryParams: {}, + errorConvention: "not-found", layoutModules: [rootLayout], metadataRoutes: [], params: {}, diff --git a/tests/e2e/cloudflare-sentry-app/sentry.spec.ts b/tests/e2e/cloudflare-sentry-app/sentry.spec.ts index 909193f59..fe0a1ca66 100644 --- a/tests/e2e/cloudflare-sentry-app/sentry.spec.ts +++ b/tests/e2e/cloudflare-sentry-app/sentry.spec.ts @@ -42,7 +42,11 @@ async function expectErrorTraceCorrelation( .toBe(true); } -async function expectReportedTransaction(request: APIRequestContext, name: string) { +async function expectReportedTransaction( + request: APIRequestContext, + name: string, + predicate: (transaction: ReportedTransaction) => boolean = () => true, +) { let transaction: ReportedTransaction | undefined; await expect @@ -50,7 +54,9 @@ async function expectReportedTransaction(request: APIRequestContext, name: strin const stateRes = await request.get("/api/sentry-test-state"); expect(stateRes.status()).toBe(200); const state = (await stateRes.json()) as { transactions: ReportedTransaction[] }; - transaction = state.transactions.find((candidate) => candidate.name === name); + transaction = state.transactions.find( + (candidate) => candidate.name === name && predicate(candidate), + ); return transaction !== undefined; }) .toBe(true); @@ -246,6 +252,49 @@ test.describe("Sentry on Cloudflare Workers App Router", () => { }); }); + // Ported from Next.js: test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts + test("reports generated metadata beneath the render framework span", async ({ request }) => { + const traceRes = await request.get("/trace-metadata/product-42"); + expect(traceRes.status()).toBe(200); + + const transaction = await expectReportedTransaction( + request, + "GET /trace-metadata/[slug]", + ({ spans }) => + spans.some( + ({ attributes }) => attributes["next.span_type"] === "ResolveMetadata.generateMetadata", + ), + ); + const renderSpan = transaction.spans.find( + ({ attributes }) => attributes["next.span_type"] === "AppRender.getBodyResult", + ); + const metadataSpan = transaction.spans.find( + ({ attributes }) => + attributes["next.span_type"] === "ResolveMetadata.generateMetadata" && + attributes["next.page"] === "/trace-metadata/[slug]/page", + ); + expect(metadataSpan).toMatchObject({ + attributes: expect.objectContaining({ + "next.page": "/trace-metadata/[slug]/page", + "next.span_name": "generateMetadata /trace-metadata/[slug]/page", + "next.span_type": "ResolveMetadata.generateMetadata", + }), + name: "generateMetadata /trace-metadata/[slug]/page", + parentSpanId: renderSpan?.spanId, + traceId: transaction.traceId, + }); + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ "fixture.slug": "product-42" }), + name: "fixture.app.metadata.child", + operation: "fixture.metadata", + parentSpanId: metadataSpan?.spanId, + traceId: transaction.traceId, + }), + ); + }); + test("does not emit an App render span for an RSC payload request", async ({ request }) => { const traceRes = await request.get("/trace-page/product-42?_rsc", { headers: { Accept: "text/x-component", RSC: "1" }, diff --git a/tests/fixtures/cf-sentry-app/app/trace-metadata/[slug]/page.tsx b/tests/fixtures/cf-sentry-app/app/trace-metadata/[slug]/page.tsx new file mode 100644 index 000000000..67ed1c167 --- /dev/null +++ b/tests/fixtures/cf-sentry-app/app/trace-metadata/[slug]/page.tsx @@ -0,0 +1,23 @@ +import * as Sentry from "@sentry/nextjs"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return Sentry.startSpan( + { + attributes: { "fixture.slug": slug }, + name: "fixture.app.metadata.child", + op: "fixture.metadata", + }, + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { title: `Traced metadata: ${slug}` }; + }, + ); +} + +export default async function TraceMetadataPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + return
Traced metadata for {slug}
; +}