feat(devops): separate public page server timing diagnostics
CI / check (push) Successful in 2m23s
CI / deploy (push) Successful in 1m13s
CI / publish-container (push) Successful in 44s

This commit is contained in:
Simo committed 2026-09-11 10:49:08 +02:00
1 parent d5091d1a73
commit baeb54aeb5
15 files changed
+315 -29

No files matched your search

+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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 -5
View File
@@ -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);
+4 -1
View File
@@ -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);
+37 -1
View File
@@ -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&amp;q=&amp;sort=slowest&amp;slow=0",
);
expect(html).not.toContain("HTTP 200");
expect(html).toContain("browser rendering");
});
+29 -4
View File
@@ -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
+19
View File
@@ -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);
});
+16 -5
View File
@@ -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>(
+17
View File
@@ -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");
});
+28 -9
View File
@@ -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,
};
}
+76
View File
@@ -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();
}
});
+61
View File
@@ -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;
});
};
}