Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/vinext/src/server/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type PagesReqResRequest,
type PagesReqResResponse,
} from "./pages-node-compat.js";
import { tracePagesApiHandler } from "./pages-execution-tracing.js";

/**
* Extend the Node.js request with Next.js-style helpers.
Expand Down Expand Up @@ -416,7 +417,9 @@ export async function handleApiRoute(
}
: undefined,
);
const handlerResponse = await apiModule.default(nextRequest);
const handlerResponse = await tracePagesApiHandler(route.pattern, () =>
apiModule.default(nextRequest),
);
if (!(handlerResponse instanceof Response)) {
throw new Error("Edge API route did not return a Response");
}
Expand Down Expand Up @@ -473,7 +476,7 @@ export async function handleApiRoute(
);

// Call the handler
await handler(apiReq, apiRes);
await tracePagesApiHandler(route.pattern, () => handler(apiReq, apiRes));
return true;
} catch (e) {
if (e instanceof PagesBodyParseError) {
Expand Down
23 changes: 15 additions & 8 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
type PagesPreviewState,
} from "./pages-preview.js";
import { isBotUserAgent } from "../utils/html-limited-bots.js";
import { tracePagesData } from "./pages-execution-tracing.js";

/**
* Render a React element to a string using renderToReadableStream.
Expand Down Expand Up @@ -1100,7 +1101,9 @@ export function createSSRHandler(
defaultLocale: currentDefaultLocale,
...previewContext,
};
const result = await pageModule.getServerSideProps(context);
const result = await tracePagesData("getServerSideProps", route.pattern, () =>
pageModule.getServerSideProps!(context),
);
// If gSSP called res.end() directly (short-circuit pattern),
// the response is already sent. Do not continue rendering.
// Note: middleware headers are already on `res` (middleware runs
Expand Down Expand Up @@ -1258,7 +1261,9 @@ export function createSSRHandler(
return;
}

const result = await pageModule.getStaticProps(context);
const result = await tracePagesData("getStaticProps", route.pattern, () =>
pageModule.getStaticProps!(context),
);
const routePattern = patternToNextFormat(route.pattern);
assertPages404DoesNotReturnNotFound(routePattern, result);
if (result) {
Expand Down Expand Up @@ -1849,12 +1854,14 @@ async function renderErrorPage(
const isOnDemandRevalidate = isOnDemandRevalidateRequest(
req.headers[PRERENDER_REVALIDATE_HEADER],
);
const staticResult = await errorModule.getStaticProps({
locale: context.locale,
locales: context.locales,
defaultLocale: context.defaultLocale,
revalidateReason: isOnDemandRevalidate ? "on-demand" : "stale",
});
const staticResult = await tracePagesData("getStaticProps", errorPage, () =>
errorModule.getStaticProps({
locale: context.locale,
locales: context.locales,
defaultLocale: context.defaultLocale,
revalidateReason: isOnDemandRevalidate ? "on-demand" : "stale",
}),
);
assertPages404DoesNotReturnNotFound(errorPage, staticResult);
if (staticResult?.redirect) {
applyDevPagesCacheHeaders(
Expand Down
6 changes: 4 additions & 2 deletions packages/vinext/src/server/pages-api-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type ExecutionContextLike,
} from "vinext/shims/request-context";
import { NextRequest } from "vinext/shims/server";
import { tracePagesApiHandler } from "./pages-execution-tracing.js";

type PagesApiRouteConfig = {
runtime?: string;
Expand Down Expand Up @@ -158,6 +159,7 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis

try {
if (isEdgeApiRouteModule(route.module)) {
const handler = route.module.default;
// Next.js wraps the incoming Request in a NextRequest before invoking
// edge API handlers, so handlers can use `req.nextUrl.searchParams`,
// `req.cookies`, etc. (Cf. NextRequestHint in next/src/server/web/adapter.ts.)
Expand All @@ -173,7 +175,7 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis
}
: undefined,
);
const response = await route.module.default(nextRequest);
const response = await tracePagesApiHandler(route.pattern, () => handler(nextRequest));
if (response instanceof Response) {
const finalized = finalizeEdgeApiResponse(response, options.edgeRuntime ?? "worker");
if (
Expand Down Expand Up @@ -284,7 +286,7 @@ async function _handlePagesApiRoute(options: HandlePagesApiRouteOptions): Promis
// handlers attached. A synchronous throw may destroy the response bridge,
// which rejects responsePromise as well as the handler completion.
const handlerCompletion = Promise.resolve()
.then(() => handler(req, res))
.then(() => tracePagesApiHandler(route.pattern, () => handler(req, res)))
.then(() => ({ type: "handler" as const }), destroyAfterHandlerError);

// A real Node ServerResponse is consumed by the socket while the API
Expand Down
33 changes: 33 additions & 0 deletions packages/vinext/src/server/pages-execution-tracing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { patternToNextFormat } from "../routing/route-validation.js";
import { frameworkTracer } from "./tracer.js";

type PagesDataMethod = "getServerSideProps" | "getStaticProps";

export function createPagesDataSpanDescriptor(method: PagesDataMethod, routePattern: string) {
const route = patternToNextFormat(routePattern);
return {
attributes: { "next.route": route },
name: `${method} ${route}`,
type: `Render.${method}`,
} as const;
}

export function tracePagesData<T>(
method: PagesDataMethod,
routePattern: string,
callback: () => T,
): T {
return frameworkTracer.trace(createPagesDataSpanDescriptor(method, routePattern), callback);
}

export function createPagesApiHandlerSpanDescriptor(routePattern: string) {
const route = patternToNextFormat(routePattern);
return {
name: `executing api route (pages) ${route}`,
type: "Node.runHandler",
} as const;
}

export function tracePagesApiHandler<T>(routePattern: string, callback: () => T): T {
return frameworkTracer.trace(createPagesApiHandlerSpanDescriptor(routePattern), callback);
}
67 changes: 37 additions & 30 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { isBotUserAgent } from "../utils/html-limited-bots.js";
import { isUnknownRecord } from "../utils/record.js";
import { isDangerousScheme } from "vinext/shims/url-safety";
import { encodeCacheTag } from "../utils/encode-cache-tag.js";
import { tracePagesData } from "./pages-execution-tracing.js";

export type PagesRedirectResult = {
destination: string;
Expand Down Expand Up @@ -1332,17 +1333,19 @@ export async function resolvePagesPageData(
}
renderProps = { ...renderProps, __N_SSP: true };
const { req, res, responsePromise } = getSharedReqRes();
const result = await options.pageModule.getServerSideProps({
params: userFacingParams,
req,
res,
query: options.query,
resolvedUrl: options.resolvedUrl ?? options.routeUrl,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
...previewContext,
});
const result = await tracePagesData("getServerSideProps", options.routePattern, () =>
options.pageModule.getServerSideProps!({
params: userFacingParams,
req,
res,
query: options.query,
resolvedUrl: options.resolvedUrl ?? options.routeUrl,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
...previewContext,
}),
);

if (isResponseSent(res)) {
return {
Expand Down Expand Up @@ -1430,13 +1433,15 @@ export async function resolvePagesPageData(

let freshPageProps = freshAppResult.pageProps;
let freshRenderProps = freshAppResult.renderProps;
const freshResult = await options.pageModule.getStaticProps?.({
params: userFacingParams,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
revalidateReason: "stale",
});
const freshResult = await tracePagesData("getStaticProps", options.routePattern, () =>
options.pageModule.getStaticProps?.({
params: userFacingParams,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
revalidateReason: "stale",
}),
);
if (!freshResult) return;
assertPages404DoesNotReturnNotFound(options.routePattern, freshResult);

Expand Down Expand Up @@ -1696,18 +1701,20 @@ export async function resolvePagesPageData(
}
const result = generatedPageData
? null
: await options.pageModule.getStaticProps({
params: userFacingParams,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
...previewContext,
revalidateReason: options.isOnDemandRevalidate
? "on-demand"
: options.isBuildTimePrerendering
? "build"
: "stale",
});
: await tracePagesData("getStaticProps", options.routePattern, () =>
options.pageModule.getStaticProps!({
params: userFacingParams,
locale: options.i18n.locale,
locales: options.i18n.locales,
defaultLocale: options.i18n.defaultLocale,
...previewContext,
revalidateReason: options.isOnDemandRevalidate
? "on-demand"
: options.isBuildTimePrerendering
? "build"
: "stale",
}),
);
assertPages404DoesNotReturnNotFound(options.routePattern, result);

if (generatedPageData) {
Expand Down
77 changes: 76 additions & 1 deletion tests/e2e/cloudflare-sentry-pages/sentry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,23 @@ test.describe("Sentry on Cloudflare Workers Pages Router", () => {
expect(transaction.traceId).toMatch(/^[0-9a-f]{32}$/);
expect(transaction.spanId).toMatch(/^[0-9a-f]{16}$/);

const handlerSpan = transaction.spans.find(
({ attributes }) => attributes["next.span_type"] === "Node.runHandler",
);
expect(handlerSpan).toMatchObject({
attributes: expect.objectContaining({
"next.span_name": "executing api route (pages) /api/trace/[slug]",
"next.span_type": "Node.runHandler",
}),
name: "executing api route (pages) /api/trace/[slug]",
parentSpanId: transaction.spanId,
traceId: transaction.traceId,
});
expect(transaction.spans).toContainEqual(
expect.objectContaining({
name: "fixture.pages.child",
traceId: transaction.traceId,
parentSpanId: transaction.spanId,
parentSpanId: handlerSpan?.spanId,
operation: "fixture.child",
attributes: expect.objectContaining({
"fixture.router": "pages",
Expand All @@ -138,6 +150,69 @@ test.describe("Sentry on Cloudflare Workers Pages 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("parents getServerSideProps application spans beneath the framework span", async ({
request,
}) => {
const traceRes = await request.get("/trace-gssp/product-42");
expect(traceRes.status()).toBe(200);

const transaction = await expectReportedTransaction(request, "GET /trace-gssp/[slug]");
const dataSpan = transaction.spans.find(
({ attributes }) => attributes["next.span_type"] === "Render.getServerSideProps",
);
expect(dataSpan).toMatchObject({
attributes: expect.objectContaining({
"next.route": "/trace-gssp/[slug]",
"next.span_name": "getServerSideProps /trace-gssp/[slug]",
"next.span_type": "Render.getServerSideProps",
}),
name: "getServerSideProps /trace-gssp/[slug]",
parentSpanId: transaction.spanId,
traceId: transaction.traceId,
});
expect(transaction.spans).toContainEqual(
expect.objectContaining({
attributes: expect.objectContaining({ "fixture.slug": "product-42" }),
name: "fixture.pages.gssp.child",
operation: "fixture.gssp",
parentSpanId: dataSpan?.spanId,
traceId: transaction.traceId,
}),
);
});

test("traces request-time getStaticProps for a blocking fallback", async ({ request }) => {
const slug = `runtime-${Date.now()}`;
const traceRes = await request.get(`/trace-gsp/${slug}`);
expect(traceRes.status()).toBe(200);

const transaction = await expectReportedTransaction(request, "GET /trace-gsp/[slug]");
const dataSpan = transaction.spans.find(
({ attributes }) => attributes["next.span_type"] === "Render.getStaticProps",
);
expect(dataSpan).toMatchObject({
attributes: expect.objectContaining({
"next.route": "/trace-gsp/[slug]",
"next.span_name": "getStaticProps /trace-gsp/[slug]",
"next.span_type": "Render.getStaticProps",
}),
name: "getStaticProps /trace-gsp/[slug]",
parentSpanId: transaction.spanId,
traceId: transaction.traceId,
});
expect(transaction.spans).toContainEqual(
expect.objectContaining({
attributes: expect.objectContaining({ "fixture.slug": slug }),
name: "fixture.pages.gsp.child",
operation: "fixture.gsp",
parentSpanId: dataSpan?.spanId,
traceId: transaction.traceId,
}),
);
});

test("continues incoming Sentry traces without leaking parallel request context", async ({
request,
}) => {
Expand Down
20 changes: 20 additions & 0 deletions tests/fixtures/cf-sentry-pages/pages/trace-gsp/[slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from "@sentry/nextjs";
import type { GetStaticPaths, GetStaticProps } from "next";

export const getStaticPaths: GetStaticPaths = () => ({ fallback: "blocking", paths: [] });

export const getStaticProps: GetStaticProps<{ slug: string }> = async ({ params }) => {
const slug = String(params?.slug ?? "");
return Sentry.startSpan(
{
attributes: { "fixture.slug": slug },
name: "fixture.pages.gsp.child",
op: "fixture.gsp",
},
() => ({ props: { slug }, revalidate: 60 }),
);
};

export default function TraceGspPage({ slug }: { slug: string }) {
return <main>GSP trace: {slug}</main>;
}
18 changes: 18 additions & 0 deletions tests/fixtures/cf-sentry-pages/pages/trace-gssp/[slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from "@sentry/nextjs";
import type { GetServerSideProps } from "next";

export const getServerSideProps: GetServerSideProps<{ slug: string }> = async ({ params }) => {
const slug = String(params?.slug ?? "");
return Sentry.startSpan(
{
attributes: { "fixture.slug": slug },
name: "fixture.pages.gssp.child",
op: "fixture.gssp",
},
() => ({ props: { slug } }),
);
};

export default function TraceGsspPage({ slug }: { slug: string }) {
return <main>GSSP trace: {slug}</main>;
}
Loading
Loading