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

+8
View File
@@ -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.
+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;
});
};
}