feat(devops): separate public page server timing diagnostics
This commit is contained in:
1 parent
d5091d1a73
commit
baeb54aeb5
15 files changed
+315
-29
No files matched your search
@@ -7,6 +7,7 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { excerpt, slugify } from "@/lib/format";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadPublicEvents } from "@/lib/services/public-events";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -22,7 +23,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventsPage({
|
||||
async function EventsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
@@ -274,3 +275,5 @@ export default async function EventsPage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/events", EventsPage);
|
||||
@@ -11,6 +11,7 @@ import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { avatarImageUrl, slugify } from "@/lib/format";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadUserDashboard } from "@/lib/services/user-dashboard";
|
||||
import CopyReferralButton from "./CopyReferralButton";
|
||||
import DashboardTasks from "./DashboardTasks";
|
||||
@@ -21,7 +22,7 @@ export async function generateMetadata() {
|
||||
return { title: t("title") };
|
||||
}
|
||||
|
||||
export default async function MePage({
|
||||
async function MePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ claimed?: string; error?: string }>;
|
||||
@@ -440,3 +441,5 @@ export default async function MePage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/me", MePage);
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { sanitize } from "@/lib/sanitize";
|
||||
import { cacheNews } from "@/lib/services/news-cache";
|
||||
|
||||
@@ -92,7 +93,7 @@ const REACTIONS: { key: string; emoji: string }[] = [
|
||||
{ key: "wow", emoji: "😮" },
|
||||
];
|
||||
|
||||
export default async function ArticlePage({
|
||||
async function ArticlePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -407,3 +408,5 @@ export default async function ArticlePage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/news/[slug]", ArticlePage);
|
||||
@@ -5,11 +5,12 @@ import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadNewsArchive } from "@/lib/services/news-archive";
|
||||
|
||||
export const metadata = { title: "News" };
|
||||
|
||||
export default async function NewsPage({
|
||||
async function NewsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; page?: string; order?: string }>;
|
||||
@@ -188,3 +189,5 @@ export default async function NewsPage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/news", NewsPage);
|
||||
@@ -4,6 +4,7 @@ import Link from "@/components/link";
|
||||
import { LocalEventTime } from "@/components/public/local-event-time";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadPublicSearch } from "@/lib/services/public-search";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
@@ -17,11 +18,7 @@ type Params = {
|
||||
newsPage?: string;
|
||||
eventsPage?: string;
|
||||
};
|
||||
export default async function SearchPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Params>;
|
||||
}) {
|
||||
async function SearchPage({ searchParams }: { searchParams: Promise<Params> }) {
|
||||
const t = await getTranslations("pages.search");
|
||||
const params = await searchParams;
|
||||
const result = await loadPublicSearch({
|
||||
@@ -226,3 +223,5 @@ export default async function SearchPage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/search", SearchPage);
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { withPublicPagePerformance } from "@/lib/public-page-performance";
|
||||
import { loadProfilePrivacy } from "@/lib/services/profile-privacy";
|
||||
import { ProfilePhotoGallery } from "./ProfilePhotoGallery";
|
||||
import styles from "./profile.module.css";
|
||||
@@ -77,7 +78,7 @@ const BADGE_IMG_BASE = "https://images.habbo.com/c_images/album1584";
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
const CURRENCY_DIAMONDS = 5;
|
||||
|
||||
export default async function ProfilePage({
|
||||
async function ProfilePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -699,3 +700,5 @@ export default async function ProfilePage({
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPublicPagePerformance("/u/[username]", ProfilePage);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTranslator } from "next-intl";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import messages from "@/messages/en.json";
|
||||
@@ -18,7 +19,9 @@ vi.mock("next/navigation", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/performance-store", () => ({ readPerformance: mocks.read }));
|
||||
vi.mock("@/components/link", () => ({ default: () => null }));
|
||||
vi.mock("@/components/link", () => ({
|
||||
default: ({ href }: { href: string }) => createElement("a", { href }),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: async () =>
|
||||
@@ -75,3 +78,36 @@ it("renders filtered slow records and explains incomplete streaming timings", as
|
||||
expect(html).toContain("Streaming response");
|
||||
expect(html).toContain("Local diagnostics");
|
||||
});
|
||||
it("separates public component outcomes from HTTP response codes", async () => {
|
||||
mocks.allowed = true;
|
||||
mocks.read.mockResolvedValue({
|
||||
shared: false,
|
||||
samples: [
|
||||
{
|
||||
kind: "public-page",
|
||||
outcome: "redirect",
|
||||
operationId: "public-op",
|
||||
route: "/me",
|
||||
method: "PAGE",
|
||||
status: 200,
|
||||
at: 1000,
|
||||
streaming: false,
|
||||
metrics: {
|
||||
durationMs: 2,
|
||||
database: { count: 0, durationMs: 0, errors: 0 },
|
||||
external: { count: 0, durationMs: 0, errors: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
await Page({ searchParams: Promise.resolve({ kind: "public-page" }) }),
|
||||
);
|
||||
expect(mocks.read).toHaveBeenLastCalledWith("public-page");
|
||||
expect(html).toContain("Redirect");
|
||||
expect(html).toContain(
|
||||
"/admin/devops/performance?kind=public-page&q=&sort=slowest&slow=0",
|
||||
);
|
||||
expect(html).not.toContain("HTTP 200");
|
||||
expect(html).toContain("browser rendering");
|
||||
});
|
||||
@@ -13,9 +13,16 @@ export default async function PerformancePage({
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.performance");
|
||||
const { samples, shared } = await readPerformance();
|
||||
const params = await searchParams;
|
||||
const kind = params.kind === "public-page" ? "public-page" : "admin-api";
|
||||
const { samples, shared } = await readPerformance(kind);
|
||||
const query = (typeof params.q === "string" ? params.q : "").slice(0, 100);
|
||||
const refreshParams = new URLSearchParams({
|
||||
kind,
|
||||
q: query,
|
||||
sort: params.sort === "recent" ? "recent" : "slowest",
|
||||
slow: params.slow === "1" ? "1" : "0",
|
||||
});
|
||||
const visible = samples.filter(
|
||||
(sample) =>
|
||||
`${sample.route} ${sample.operationId}`.includes(query) &&
|
||||
@@ -36,7 +43,10 @@ export default async function PerformancePage({
|
||||
<Link href="/admin/devops" className="btn btn-outline">
|
||||
DevOps
|
||||
</Link>
|
||||
<Link href="/admin/devops/performance" className="btn btn-outline">
|
||||
<Link
|
||||
href={`/admin/devops/performance?${refreshParams}`}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("refresh")}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -44,6 +54,15 @@ export default async function PerformancePage({
|
||||
{t(shared ? "shared" : "local")} {t("retention")}
|
||||
</p>
|
||||
<form className="admin-card flex flex-wrap gap-3">
|
||||
<select
|
||||
name="kind"
|
||||
defaultValue={kind}
|
||||
aria-label={t("kind")}
|
||||
className="input"
|
||||
>
|
||||
<option value="admin-api">{t("adminApi")}</option>
|
||||
<option value="public-page">{t("publicPage")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
@@ -71,10 +90,13 @@ export default async function PerformancePage({
|
||||
<option value="1">{t("slow")}</option>
|
||||
</select>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
{t("search")}
|
||||
{t("applyFilters")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">{t("explanation")}</p>
|
||||
{kind === "public-page" && (
|
||||
<p className="text-sm text-muted-foreground">{t("publicBoundary")}</p>
|
||||
)}
|
||||
{visible.length === 0 ? (
|
||||
<p className="admin-card">{t("empty")}</p>
|
||||
) : (
|
||||
@@ -90,7 +112,10 @@ export default async function PerformancePage({
|
||||
{sample.method} {sample.route}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(sample.at).toISOString()} · HTTP {sample.status}
|
||||
{new Date(sample.at).toISOString()} ·{" "}
|
||||
{sample.kind === "public-page"
|
||||
? t(`outcomes.${sample.outcome ?? "completed"}`)
|
||||
: `HTTP ${sample.status}`}
|
||||
</p>
|
||||
</div>
|
||||
<strong
|
||||
|
||||
@@ -85,3 +85,22 @@ it("does not change a completed snapshot when background work finishes", async (
|
||||
await background;
|
||||
expect(result.metrics.external.count).toBe(0);
|
||||
});
|
||||
|
||||
it("reports timings on failure without replacing the application error", async () => {
|
||||
const error = new Error("original");
|
||||
let captured = 0;
|
||||
await expect(
|
||||
collectPerformance(
|
||||
async () => {
|
||||
await measureDependency("database", async () => {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
(metrics) => {
|
||||
captured = metrics.database.errors;
|
||||
throw Error("telemetry failure");
|
||||
},
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
expect(captured).toBe(1);
|
||||
});
|
||||
@@ -7,17 +7,28 @@ export type PerformanceMetrics = Record<Dependency, Measurement> & {
|
||||
};
|
||||
const context = new AsyncLocalStorage<PerformanceMetrics>();
|
||||
|
||||
export async function collectPerformance<T>(work: () => Promise<T>) {
|
||||
export async function collectPerformance<T>(
|
||||
work: () => Promise<T>,
|
||||
onComplete?: (metrics: PerformanceMetrics) => void,
|
||||
) {
|
||||
const metrics: PerformanceMetrics = {
|
||||
durationMs: 0,
|
||||
database: { count: 0, durationMs: 0, errors: 0 },
|
||||
external: { count: 0, durationMs: 0, errors: 0 },
|
||||
};
|
||||
const start = performance.now();
|
||||
const value = await context.run(metrics, work);
|
||||
metrics.durationMs = performance.now() - start;
|
||||
// Background work may keep the async context: return a detached snapshot.
|
||||
return { value, metrics: structuredClone(metrics) };
|
||||
try {
|
||||
const value = await context.run(metrics, work);
|
||||
metrics.durationMs = performance.now() - start;
|
||||
return { value, metrics: structuredClone(metrics) };
|
||||
} finally {
|
||||
metrics.durationMs = performance.now() - start;
|
||||
try {
|
||||
onComplete?.(structuredClone(metrics));
|
||||
} catch {
|
||||
/* Diagnostics must not change application outcomes. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function measureDependency<T>(
|
||||
|
||||
@@ -68,3 +68,20 @@ it("ignores expired and malformed shared records", async () => {
|
||||
expect(result.shared).toBe(true);
|
||||
expect(result.samples).toEqual([sample]);
|
||||
});
|
||||
|
||||
it("keeps public traffic separate from retained HK operations", async () => {
|
||||
mock.status = "end";
|
||||
recordPerformance({ ...sample, operationId: "hk-retained" });
|
||||
for (let n = 0; n < 230; n++)
|
||||
recordPerformance({
|
||||
...sample,
|
||||
kind: "public-page",
|
||||
outcome: "completed",
|
||||
operationId: `public-${n}`,
|
||||
});
|
||||
const admin = await readPerformance();
|
||||
const publicPages = await readPerformance("public-page");
|
||||
expect(admin.samples[0].operationId).toBe("hk-retained");
|
||||
expect(publicPages.samples).toHaveLength(200);
|
||||
expect(publicPages.samples[0].operationId).toBe("public-229");
|
||||
});
|
||||
@@ -11,6 +11,8 @@ const measurement = z.object({
|
||||
errors: z.number().nonnegative(),
|
||||
});
|
||||
const schema = z.object({
|
||||
kind: z.enum(["admin-api", "public-page"]).optional(),
|
||||
outcome: z.enum(["completed", "failed", "redirect", "not-found"]).optional(),
|
||||
operationId: z.string().max(100),
|
||||
route: z.string().max(300),
|
||||
method: z.string().max(10),
|
||||
@@ -24,6 +26,8 @@ const schema = z.object({
|
||||
}),
|
||||
});
|
||||
export interface PerformanceSample {
|
||||
kind?: "admin-api" | "public-page";
|
||||
outcome?: "completed" | "failed" | "redirect" | "not-found";
|
||||
operationId: string;
|
||||
route: string;
|
||||
method: string;
|
||||
@@ -33,23 +37,33 @@ export interface PerformanceSample {
|
||||
metrics: PerformanceMetrics;
|
||||
}
|
||||
const state = globalThis as typeof globalThis & {
|
||||
cmsPerformance?: { samples: PerformanceSample[]; pending: number };
|
||||
cmsPerformance?: {
|
||||
samples: PerformanceSample[];
|
||||
publicSamples?: PerformanceSample[];
|
||||
pending: number;
|
||||
};
|
||||
};
|
||||
const buffer = state.cmsPerformance ?? { samples: [], pending: 0 };
|
||||
state.cmsPerformance = buffer;
|
||||
|
||||
/** Best effort: no connection wait, no unbounded offline queue, no request failure. */
|
||||
export function recordPerformance(sample: PerformanceSample): void {
|
||||
buffer.samples.unshift(sample);
|
||||
buffer.samples.length = Math.min(buffer.samples.length, LIMIT);
|
||||
const samples =
|
||||
sample.kind === "public-page"
|
||||
? (buffer.publicSamples ?? [])
|
||||
: buffer.samples;
|
||||
if (sample.kind === "public-page") buffer.publicSamples = samples;
|
||||
samples.unshift(sample);
|
||||
samples.length = Math.min(samples.length, LIMIT);
|
||||
const key = sample.kind === "public-page" ? `${KEY}:public` : KEY;
|
||||
if (redis?.status !== "ready" || buffer.pending >= 4) return;
|
||||
buffer.pending++;
|
||||
try {
|
||||
void redis
|
||||
.multi()
|
||||
.lpush(KEY, JSON.stringify(sample))
|
||||
.ltrim(KEY, 0, LIMIT - 1)
|
||||
.expire(KEY, 3600)
|
||||
.lpush(key, JSON.stringify(sample))
|
||||
.ltrim(key, 0, LIMIT - 1)
|
||||
.expire(key, 3600)
|
||||
.exec()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
@@ -60,16 +74,21 @@ export function recordPerformance(sample: PerformanceSample): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPerformance(): Promise<{
|
||||
export async function readPerformance(
|
||||
kind: "admin-api" | "public-page" = "admin-api",
|
||||
): Promise<{
|
||||
samples: PerformanceSample[];
|
||||
shared: boolean;
|
||||
}> {
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
const key = kind === "public-page" ? `${KEY}:public` : KEY;
|
||||
const local =
|
||||
kind === "public-page" ? (buffer.publicSamples ?? []) : buffer.samples;
|
||||
if (redis?.status === "ready") {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const rows = await Promise.race([
|
||||
redis.lrange(KEY, 0, LIMIT - 1),
|
||||
redis.lrange(key, 0, LIMIT - 1),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Diagnostic storage timeout")),
|
||||
@@ -95,7 +114,7 @@ export async function readPerformance(): Promise<{
|
||||
}
|
||||
}
|
||||
return {
|
||||
samples: buffer.samples.filter((sample) => sample.at >= cutoff),
|
||||
samples: local.filter((sample) => sample.at >= cutoff),
|
||||
shared: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { measureDependency } from "./performance-diagnostics";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ record: vi.fn() }));
|
||||
vi.mock("./performance-store", () => ({ recordPerformance: mocks.record }));
|
||||
|
||||
import { withPublicPagePerformance } from "./public-page-performance";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.record.mockClear();
|
||||
});
|
||||
it("measures concurrent pages with static labels and never stores props", async () => {
|
||||
const render = withPublicPagePerformance(
|
||||
"/u/[username]",
|
||||
async (props: { username: string }) => {
|
||||
await measureDependency("database", async () => 1);
|
||||
return props.username;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await Promise.all([
|
||||
render({ username: "PrivateName" }),
|
||||
render({ username: "AnotherName" }),
|
||||
]),
|
||||
).toEqual(["PrivateName", "AnotherName"]);
|
||||
const records = mocks.record.mock.calls.map(([record]) => record);
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[0].metrics.database.count).toBe(1);
|
||||
expect(records[0].operationId).not.toBe(records[1].operationId);
|
||||
expect(JSON.stringify(records)).not.toMatch(/PrivateName|AnotherName/);
|
||||
expect(records[0]).toMatchObject({
|
||||
kind: "public-page",
|
||||
route: "/u/[username]",
|
||||
outcome: "completed",
|
||||
});
|
||||
});
|
||||
it("preserves framework redirects and labels them separately from failures", async () => {
|
||||
const error = Object.assign(new Error("private redirect"), {
|
||||
digest: "NEXT_REDIRECT;replace;/login;307;",
|
||||
});
|
||||
const render = withPublicPagePerformance("/me", async () => {
|
||||
throw error;
|
||||
});
|
||||
await expect(render(undefined)).rejects.toBe(error);
|
||||
expect(mocks.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: "redirect" }),
|
||||
);
|
||||
expect(JSON.stringify(mocks.record.mock.calls)).not.toContain("/login");
|
||||
});
|
||||
it("keeps missing pages distinct and records dependency failures", async () => {
|
||||
const error = Object.assign(new Error("private"), {
|
||||
digest: "NEXT_HTTP_ERROR_FALLBACK;404",
|
||||
});
|
||||
await expect(
|
||||
withPublicPagePerformance("/news/[slug]", async () => {
|
||||
throw error;
|
||||
})(undefined),
|
||||
).rejects.toBe(error);
|
||||
expect(mocks.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ outcome: "not-found" }),
|
||||
);
|
||||
});
|
||||
it("excludes static build invocations without changing their result", async () => {
|
||||
vi.stubEnv("NEXT_PHASE", "phase-production-build");
|
||||
try {
|
||||
expect(
|
||||
await withPublicPagePerformance(
|
||||
"/news",
|
||||
async () => "content",
|
||||
)(undefined),
|
||||
).toBe("content");
|
||||
expect(mocks.record).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createStore, runWithStore } from "./foundation/request-context";
|
||||
import type { IpAddress } from "./foundation/types";
|
||||
import { collectPerformance } from "./performance-diagnostics";
|
||||
import { type PerformanceSample, recordPerformance } from "./performance-store";
|
||||
|
||||
type PublicRoute =
|
||||
| "/me"
|
||||
| "/news"
|
||||
| "/news/[slug]"
|
||||
| "/u/[username]"
|
||||
| "/events"
|
||||
| "/search";
|
||||
/** Measures this server component invocation, not child rendering, streaming or browser load. */
|
||||
export function withPublicPagePerformance<Props, Result>(
|
||||
route: PublicRoute,
|
||||
render: (props: Props) => Promise<Result>,
|
||||
) {
|
||||
return async (props: Props): Promise<Result> => {
|
||||
if (
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_PHASE === "phase-production-compile"
|
||||
)
|
||||
return render(props);
|
||||
const store = createStore("unknown" as IpAddress);
|
||||
let outcome: PerformanceSample["outcome"] = "completed";
|
||||
return runWithStore(store, async () => {
|
||||
const result = await collectPerformance(
|
||||
async () => {
|
||||
try {
|
||||
return await render(props);
|
||||
} catch (error) {
|
||||
const digest =
|
||||
error && typeof error === "object" && "digest" in error
|
||||
? String(error.digest)
|
||||
: "";
|
||||
outcome = digest.startsWith("NEXT_REDIRECT;")
|
||||
? "redirect"
|
||||
: digest === "NEXT_HTTP_ERROR_FALLBACK;404"
|
||||
? "not-found"
|
||||
: "failed";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
(metrics) =>
|
||||
recordPerformance({
|
||||
kind: "public-page",
|
||||
outcome,
|
||||
operationId: store.requestId,
|
||||
route,
|
||||
method: "PAGE",
|
||||
status:
|
||||
outcome === "failed" ? 500 : outcome === "not-found" ? 404 : 200,
|
||||
at: Date.now(),
|
||||
streaming: false,
|
||||
metrics,
|
||||
}),
|
||||
);
|
||||
return result.value;
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user