From baeb54aeb569a5167b5923332d0a405fcb3ccec3 Mon Sep 17 00:00:00 2001
From: simoleo89
Date: Fri, 11 Sep 2026 10:49:08 +0200
Subject: [PATCH] feat(devops): separate public page server timing diagnostics
---
docs/cms-upgrade-2026-09.md | 8 ++
src/app/(site)/events/page.tsx | 5 +-
src/app/(site)/me/page.tsx | 5 +-
src/app/(site)/news/[...slug]/page.tsx | 5 +-
src/app/(site)/news/page.tsx | 5 +-
src/app/(site)/search/page.tsx | 9 +--
src/app/(site)/u/[username]/page.tsx | 5 +-
src/app/admin/devops/performance/page.test.ts | 38 +++++++++-
src/app/admin/devops/performance/page.tsx | 33 +++++++-
src/lib/performance-diagnostics.test.ts | 19 +++++
src/lib/performance-diagnostics.ts | 21 +++--
src/lib/performance-store.test.ts | 17 +++++
src/lib/performance-store.ts | 37 ++++++---
src/lib/public-page-performance.test.ts | 76 +++++++++++++++++++
src/lib/public-page-performance.ts | 61 +++++++++++++++
15 files changed, 315 insertions(+), 29 deletions(-)
create mode 100644 src/lib/public-page-performance.test.ts
create mode 100644 src/lib/public-page-performance.ts
diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md
index 82dda276..fe96eb84 100644
--- a/docs/cms-upgrade-2026-09.md
+++ b/docs/cms-upgrade-2026-09.md
@@ -146,3 +146,11 @@ The recovery pass checks at most eight eligible sources, excludes the selected s
Import history displays the current phase and elapsed time, the last phase on failure/interruption, and the source/revision of a recovered bundle. Phases are persisted under the existing worker lease; a retry clears old phase/provenance fields. Synchronization jobs also report their existing importer phases, but their clone-specific asset strategy is unchanged.
No additional secrets or environment variables are needed. Source definitions remain managed through the existing source configuration.
+
+## Catalog workflow and public diagnostics
+
+- Bulk offer edits retain the existing preview and now record complete price/category snapshots. The success notification offers an atomic batch Undo for 15 seconds; individual changes remain restorable from audit history afterward. Undo rejects changed records or missing categories rather than overwriting newer edits. No additional migration is needed beyond the existing history snapshot migration.
+- Catalog Studio preserves the existing in-place search/filter/selection flow and now restores list scroll and focus after closing furniture details. Escape closes details before clearing selection. Obsolete list responses cannot replace a newer search; switching source invalidates pending review preparation.
+- Import review groups furniture needing completion, conflicting records and unverified components. Each row lists missing or unknown components and suggests the next action. These are inspection recommendations; final import validation remains authoritative.
+- DevOps performance has separate HK API and public-page groups, each limited to 200 recent samples for one hour. Public instrumentation measures root server page invocations on /me, news, profiles, events and search, including measured database/external work. It excludes metadata, separately rendered children, cached responses that do not invoke the page, network transfer and browser rendering. Static build invocations are excluded. Routes use fixed labels without usernames or search terms; component outcomes are distinct from HTTP status codes.
+- Shared unsaved-change protection now coordinates dirty forms and protects global-search navigation. Prefix, badge and room-furniture editors protect explicit dismissal and remain open after failed saves. Browser Back is intercepted when the cancellable Navigation API is available; reload/close and links retain their existing protection. Prefix saving now waits for the real server action result before closing.
diff --git a/src/app/(site)/events/page.tsx b/src/app/(site)/events/page.tsx
index 4eb9d97e..42775135 100644
--- a/src/app/(site)/events/page.tsx
+++ b/src/app/(site)/events/page.tsx
@@ -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 {
@@ -22,7 +23,7 @@ export async function generateMetadata(): Promise {
};
}
-export default async function EventsPage({
+async function EventsPage({
searchParams,
}: {
searchParams: Promise<{
@@ -274,3 +275,5 @@ export default async function EventsPage({
);
}
+
+export default withPublicPagePerformance("/events", EventsPage);
diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx
index 306028e9..b18d004e 100644
--- a/src/app/(site)/me/page.tsx
+++ b/src/app/(site)/me/page.tsx
@@ -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({
);
}
+
+export default withPublicPagePerformance("/me", MePage);
diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx
index 2dddeeac..a5b07b12 100644
--- a/src/app/(site)/news/[...slug]/page.tsx
+++ b/src/app/(site)/news/[...slug]/page.tsx
@@ -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({
);
}
+
+export default withPublicPagePerformance("/news/[slug]", ArticlePage);
diff --git a/src/app/(site)/news/page.tsx b/src/app/(site)/news/page.tsx
index a05b83cc..d8b506b4 100644
--- a/src/app/(site)/news/page.tsx
+++ b/src/app/(site)/news/page.tsx
@@ -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({
);
}
+
+export default withPublicPagePerformance("/news", NewsPage);
diff --git a/src/app/(site)/search/page.tsx b/src/app/(site)/search/page.tsx
index 6015f468..808d7f08 100644
--- a/src/app/(site)/search/page.tsx
+++ b/src/app/(site)/search/page.tsx
@@ -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 {
@@ -17,11 +18,7 @@ type Params = {
newsPage?: string;
eventsPage?: string;
};
-export default async function SearchPage({
- searchParams,
-}: {
- searchParams: Promise;
-}) {
+async function SearchPage({ searchParams }: { searchParams: Promise }) {
const t = await getTranslations("pages.search");
const params = await searchParams;
const result = await loadPublicSearch({
@@ -226,3 +223,5 @@ export default async function SearchPage({
);
}
+
+export default withPublicPagePerformance("/search", SearchPage);
diff --git a/src/app/(site)/u/[username]/page.tsx b/src/app/(site)/u/[username]/page.tsx
index 35d6247c..46421427 100644
--- a/src/app/(site)/u/[username]/page.tsx
+++ b/src/app/(site)/u/[username]/page.tsx
@@ -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({
);
}
+
+export default withPublicPagePerformance("/u/[username]", ProfilePage);
diff --git a/src/app/admin/devops/performance/page.test.ts b/src/app/admin/devops/performance/page.test.ts
index 1d63909c..621bc50e 100644
--- a/src/app/admin/devops/performance/page.test.ts
+++ b/src/app/admin/devops/performance/page.test.ts
@@ -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");
+});
diff --git a/src/app/admin/devops/performance/page.tsx b/src/app/admin/devops/performance/page.tsx
index c0d4aaf7..01daf1e7 100644
--- a/src/app/admin/devops/performance/page.tsx
+++ b/src/app/admin/devops/performance/page.tsx
@@ -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({
DevOps
-
+
{t("refresh")}
@@ -44,6 +54,15 @@ export default async function PerformancePage({
{t(shared ? "shared" : "local")} {t("retention")}
{t("explanation")}
+ {kind === "public-page" && (
+ {t("publicBoundary")}
+ )}
{visible.length === 0 ? (
{t("empty")}
) : (
@@ -90,7 +112,10 @@ export default async function PerformancePage({
{sample.method} {sample.route}
- {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}`}
{
+ 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);
+});
diff --git a/src/lib/performance-diagnostics.ts b/src/lib/performance-diagnostics.ts
index 5ad7938e..746e149f 100644
--- a/src/lib/performance-diagnostics.ts
+++ b/src/lib/performance-diagnostics.ts
@@ -7,17 +7,28 @@ export type PerformanceMetrics = Record & {
};
const context = new AsyncLocalStorage();
-export async function collectPerformance(work: () => Promise) {
+export async function collectPerformance(
+ work: () => Promise,
+ 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(
diff --git a/src/lib/performance-store.test.ts b/src/lib/performance-store.test.ts
index 36c28f4a..ad04751a 100644
--- a/src/lib/performance-store.test.ts
+++ b/src/lib/performance-store.test.ts
@@ -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");
+});
diff --git a/src/lib/performance-store.ts b/src/lib/performance-store.ts
index aa48e60d..951dcfeb 100644
--- a/src/lib/performance-store.ts
+++ b/src/lib/performance-store.ts
@@ -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 | undefined;
try {
const rows = await Promise.race([
- redis.lrange(KEY, 0, LIMIT - 1),
+ redis.lrange(key, 0, LIMIT - 1),
new Promise((_, 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,
};
}
diff --git a/src/lib/public-page-performance.test.ts b/src/lib/public-page-performance.test.ts
new file mode 100644
index 00000000..f6289fef
--- /dev/null
+++ b/src/lib/public-page-performance.test.ts
@@ -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();
+ }
+});
diff --git a/src/lib/public-page-performance.ts b/src/lib/public-page-performance.ts
new file mode 100644
index 00000000..b3022451
--- /dev/null
+++ b/src/lib/public-page-performance.ts
@@ -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(
+ route: PublicRoute,
+ render: (props: Props) => Promise,
+) {
+ return async (props: Props): Promise => {
+ 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;
+ });
+ };
+}