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
@@ -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