diff --git a/docs/operations/cms-error-center.md b/docs/operations/cms-error-center.md
index 6b3458aa..1395457a 100644
--- a/docs/operations/cms-error-center.md
+++ b/docs/operations/cms-error-center.md
@@ -63,3 +63,23 @@ Obsolete global overrides were removed; a scoped esbuild override remains becaus
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
Kit; replacing the ORM is not warranted for this tooling issue.
+
+## HK request performance
+
+Open **DevOps → Request performance** (`/admin/devops/performance`). Access requires `DEVOPS_VIEW`; collection only retains requests that passed authentication and permission checks through `withAdmin`.
+
+The view shows recent requests, their server duration, completed database query count/time/errors, monitored curl download count/time/errors, HTTP status and the existing operation ID. Search by route or operation ID, sort by duration or recency, and filter requests taking at least one second.
+
+### Measurement boundaries
+
+- Duration ends when the handler returns its response. Streaming completion, background jobs, browser rendering and public pages are not measured.
+- Database spans wrap the shared mysql2 promise pool and transaction connection query/execute calls. Query parameters and SQL are never collected. Explicit transaction connection acquisition and transaction control methods are not separate spans.
+- External spans currently cover `curlFetchText` and `curlDownload`; ordinary fetch calls and other integrations are not covered.
+- Concurrent dependency durations can exceed wall-clock request duration. Do not subtract the sums to infer application CPU time.
+- Dynamic route values and query strings are removed. No request bodies, headers, usernames, download URLs or SQL text are stored.
+
+### Storage and operation
+
+No new environment variables or packages are required. Redis stores at most 200 recent samples under `cms:performance:v1`, with one-hour retention. Writes are best effort, restricted to an already-ready connection and at most four pending batches; the request never waits for persistence. The view reads at most 200 entries and falls back after one second if shared storage is unavailable.
+
+An in-process buffer preserves up to 200 samples during outages. The page explicitly labels this local mode; it is per instance and disappears on restart. Filtering applies to the retained samples, not complete traffic history. Under load or during storage outages, some shared samples may be omitted.
diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx
index 8c78f215..911b0d55 100644
--- a/src/app/admin/devops/page.tsx
+++ b/src/app/admin/devops/page.tsx
@@ -53,6 +53,7 @@ export default async function DevOpsPage() {
const t = await getTranslations("pages.admin.devops");
const data = await getDevOpsData();
+ const performance = await getTranslations("pages.admin.performance");
const installation = await getTranslations("pages.admin.installation");
return (
@@ -63,6 +64,9 @@ export default async function DevOpsPage() {
{installation("title")}
+
+ {performance("title")}
+
{/* Status cards */}
diff --git a/src/app/admin/devops/performance/page.test.ts b/src/app/admin/devops/performance/page.test.ts
new file mode 100644
index 00000000..1d63909c
--- /dev/null
+++ b/src/app/admin/devops/performance/page.test.ts
@@ -0,0 +1,77 @@
+import { createTranslator } from "next-intl";
+import { renderToStaticMarkup } from "react-dom/server";
+import { expect, it, vi } from "vitest";
+import messages from "@/messages/en.json";
+
+const mocks = vi.hoisted(() => ({ read: vi.fn(), allowed: false }));
+vi.mock("@/lib/permissions", () => ({
+ getAdminContext: async () => ({
+ session: { user: { rank: 1 } },
+ permissions: {},
+ }),
+ canAccess: () => mocks.allowed,
+ PERMS: { DEVOPS_VIEW: "devops.view" },
+}));
+vi.mock("next/navigation", () => ({
+ redirect: () => {
+ throw new Error("redirect");
+ },
+}));
+vi.mock("@/lib/performance-store", () => ({ readPerformance: mocks.read }));
+vi.mock("@/components/link", () => ({ default: () => null }));
+
+vi.mock("next-intl/server", () => ({
+ getTranslations: async () =>
+ createTranslator({
+ locale: "en",
+ messages: messages.pages.admin.performance,
+ }),
+}));
+
+import Page from "./page";
+
+it("rejects unauthorized users before reading timing records", async () => {
+ await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
+ "redirect",
+ );
+ expect(mocks.read).not.toHaveBeenCalled();
+});
+
+it("renders filtered slow records and explains incomplete streaming timings", async () => {
+ mocks.allowed = true;
+ const metrics = {
+ durationMs: 1500,
+ database: { count: 2, durationMs: 300, errors: 0 },
+ external: { count: 1, durationMs: 400, errors: 1 },
+ };
+ mocks.read.mockResolvedValue({
+ shared: false,
+ samples: [
+ {
+ operationId: "slow-op",
+ route: "/api/admin/studio",
+ method: "GET",
+ status: 200,
+ at: 1000,
+ streaming: true,
+ metrics,
+ },
+ {
+ operationId: "fast-op",
+ route: "/api/admin/users",
+ method: "GET",
+ status: 200,
+ at: 1001,
+ streaming: false,
+ metrics: { ...metrics, durationMs: 10 },
+ },
+ ],
+ });
+ const html = renderToStaticMarkup(
+ await Page({ searchParams: Promise.resolve({ slow: "1" }) }),
+ );
+ expect(html).toContain("slow-op");
+ expect(html).not.toContain("fast-op");
+ expect(html).toContain("Streaming response");
+ expect(html).toContain("Local diagnostics");
+});
diff --git a/src/app/admin/devops/performance/page.tsx b/src/app/admin/devops/performance/page.tsx
new file mode 100644
index 00000000..c0d4aaf7
--- /dev/null
+++ b/src/app/admin/devops/performance/page.tsx
@@ -0,0 +1,134 @@
+import { redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import Link from "@/components/link";
+import { readPerformance } from "@/lib/performance-store";
+import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
+
+export default async function PerformancePage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const { session, permissions } = await getAdminContext();
+ 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 query = (typeof params.q === "string" ? params.q : "").slice(0, 100);
+ const visible = samples.filter(
+ (sample) =>
+ `${sample.route} ${sample.operationId}`.includes(query) &&
+ (params.slow !== "1" || sample.metrics.durationMs >= 1000),
+ );
+ visible.sort((a, b) =>
+ params.sort === "recent"
+ ? b.at - a.at
+ : b.metrics.durationMs - a.metrics.durationMs,
+ );
+ return (
+
+
+
{t("title")}
+
{t("scope")}
+
+
+
+ DevOps
+
+
+ {t("refresh")}
+
+
+
+ {t(shared ? "shared" : "local")} {t("retention")}
+
+
+
{t("explanation")}
+ {visible.length === 0 ? (
+
{t("empty")}
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
index 9e3e8c5b..d487e296 100644
--- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts
+++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}`);
});
- it("validates the complete 141-row migration matrix without issues", () => {
+ it("validates the complete 142-row migration matrix without issues", () => {
const discovered = discoverLegacyPages();
const issues = validateMigrationEntries(
discovered,
HOUSEKEEPING_MIGRATION_MATRIX,
);
- expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
- expect(discovered).toHaveLength(141);
+ expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142);
+ expect(discovered).toHaveLength(142);
expect(issues).toEqual([]);
});
});
diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
index ce47a421..1d642f0d 100644
--- a/src/features/housekeeping/migration/discover-legacy-pages.test.ts
+++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages();
- expect(pages).toHaveLength(141);
+ expect(pages).toHaveLength(142);
expect(pages).toContainEqual({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts
index f603e8bd..63b93e49 100644
--- a/src/features/housekeeping/migration/matrix.test.ts
+++ b/src/features/housekeeping/migration/matrix.test.ts
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
- it("covers all 141 legacy pages exactly once", () => {
+ it("covers all 142 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
- expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(141);
+ expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(142);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts
index 138d24fc..5f2155a0 100644
--- a/src/features/housekeeping/migration/system.test.ts
+++ b/src/features/housekeeping/migration/system.test.ts
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
] as const;
describe("systemMigrationEntries", () => {
- it("covers all 21 System pages exactly once", () => {
- expect(systemMigrationEntries).toHaveLength(21);
+ it("covers all 22 System pages exactly once", () => {
+ expect(systemMigrationEntries).toHaveLength(22);
expect(
validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES),
diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts
index 4824a81a..26dae218 100644
--- a/src/features/housekeeping/migration/system.ts
+++ b/src/features/housekeeping/migration/system.ts
@@ -242,6 +242,18 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/devops/performance",
+ sourceFile: "src/app/admin/devops/performance/page.tsx",
+ targetPath: "/admin/system/observability/devops/performance",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
+ dependencies: { queries: ["readPerformance"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/emulator",
diff --git a/src/lib/api-handler-correlation.test.ts b/src/lib/api-handler-correlation.test.ts
index a4a7ba5e..3ce2b8ba 100644
--- a/src/lib/api-handler-correlation.test.ts
+++ b/src/lib/api-handler-correlation.test.ts
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
authorized: true,
permitted: true,
csrf: true,
+ record: vi.fn(),
}));
vi.mock("@/lib/admin/authorization-events", () => ({
logAuthorizationEvent: vi.fn(),
@@ -20,6 +21,7 @@ vi.mock("@/lib/permissions", () => ({
: null,
canAccess: () => state.permitted,
}));
+vi.mock("@/lib/performance-store", () => ({ recordPerformance: state.record }));
vi.mock("@/lib/server-log", () => ({
logServerError: () => "error-reference",
}));
@@ -40,6 +42,7 @@ describe("admin API correlation", () => {
state.authorized = true;
state.permitted = true;
state.csrf = true;
+ state.record.mockClear();
});
it("matches the response ID to asynchronous work without trusting a supplied ID", async () => {
const handler = withAdmin({ permission: "users.view" }, async () => {
@@ -108,6 +111,9 @@ describe("admin API correlation", () => {
controller?.enqueue(new TextEncoder().encode("data: last\n\n"));
controller?.close();
expect(await body).toBe("data: first\n\ndata: last\n\n");
+ expect(state.record).toHaveBeenCalledWith(
+ expect.objectContaining({ streaming: true }),
+ );
});
it("adds correlation to authentication, permission and handler failures", async () => {
const handler = withAdmin({ permission: "users.view" }, () => {
@@ -116,6 +122,7 @@ describe("admin API correlation", () => {
state.authorized = false;
const unauthorized = await handler(request());
expect(unauthorized.status).toBe(401);
+ expect(state.record).not.toHaveBeenCalled();
expect(unauthorized.headers.get("x-operation-id")).toBeTruthy();
state.authorized = true;
state.permitted = false;
diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts
index d9f50d70..3fe03c6f 100644
--- a/src/lib/api-handler.ts
+++ b/src/lib/api-handler.ts
@@ -7,6 +7,11 @@ import {
} from "@/lib/foundation/request-context";
import { validateCsrfToken } from "@/lib/foundation/security";
import type { IpAddress, UserId } from "@/lib/foundation/types";
+import {
+ collectPerformance,
+ routeTemplate,
+} from "@/lib/performance-diagnostics";
+import { recordPerformance } from "@/lib/performance-store";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { logServerError } from "@/lib/server-log";
@@ -37,110 +42,128 @@ export function withAdmin(
) {
return async (request: NextRequest, routeContext: RouteContext = {}) => {
const store = createStore("unknown" as IpAddress);
- const response = await runWithStore(store, async () => {
- // CSRF required for mutating admin APIs unless explicitly opted out.
- const csrfRequired =
- options.requireCsrf !== false && MUTATING_METHODS.has(request.method);
- if (csrfRequired) {
- const csrfToken =
- request.headers.get("x-csrf-token") ??
- request.headers.get("csrf-token") ??
- "";
- const valid = await validateCsrfToken(csrfToken);
- if (!valid) {
+ const { value: response, metrics } = await collectPerformance(() =>
+ runWithStore(store, async () => {
+ // CSRF required for mutating admin APIs unless explicitly opted out.
+ const csrfRequired =
+ options.requireCsrf !== false && MUTATING_METHODS.has(request.method);
+ if (csrfRequired) {
+ const csrfToken =
+ request.headers.get("x-csrf-token") ??
+ request.headers.get("csrf-token") ??
+ "";
+ const valid = await validateCsrfToken(csrfToken);
+ if (!valid) {
+ return NextResponse.json(
+ { ok: false, error: "Invalid or missing CSRF token" },
+ { status: 403 },
+ );
+ }
+ }
+
+ if (MUTATING_METHODS.has(request.method)) {
+ const contentLength = request.headers.get("content-length");
+ const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
+ if (contentLength && Number(contentLength) > maxBytes) {
+ return NextResponse.json(
+ { ok: false, error: `Request body exceeds ${maxBytes} bytes` },
+ { status: 413 },
+ );
+ }
+ }
+
+ const context = await getApiAdminContext();
+ if (!context)
return NextResponse.json(
- { ok: false, error: "Invalid or missing CSRF token" },
+ { ok: false, error: "Unauthorized" },
+ { status: 401 },
+ );
+ if (
+ options.permission &&
+ !canAccess(
+ context.permissions,
+ options.permission,
+ context.session.user.rank,
+ )
+ ) {
+ await logAuthorizationEvent({
+ kind: "permission.denied",
+ userId: context.session.user.id,
+ username: context.session.user.username,
+ rank: context.session.user.rank,
+ permission: options.permission,
+ source: request.nextUrl.pathname,
+ reason: "API permission check denied",
+ });
+ return NextResponse.json(
+ { ok: false, error: "Forbidden" },
{ status: 403 },
);
}
- }
-
- if (MUTATING_METHODS.has(request.method)) {
- const contentLength = request.headers.get("content-length");
- const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
- if (contentLength && Number(contentLength) > maxBytes) {
+ setContextUserId(Number(context.session.user.id) as UserId);
+ let finishExport: (() => Promise) | undefined;
+ try {
+ if (
+ catalogExportEnabled() &&
+ isCatalogMutation(request.method, request.nextUrl.pathname)
+ ) {
+ finishExport = await beginCatalogExport();
+ }
+ const response = await handler(request, context, routeContext);
+ if (
+ finishExport &&
+ response.body &&
+ response.headers.get("content-type")?.includes("text/event-stream")
+ ) {
+ const [client, completion] = response.body.tee();
+ const finish = finishExport;
+ after(async () => {
+ const reader = completion.getReader();
+ try {
+ while (!(await reader.read()).done) {
+ /* Wait for all import results. */
+ }
+ } finally {
+ reader.releaseLock();
+ await finish();
+ }
+ });
+ return new Response(client, {
+ status: response.status,
+ headers: response.headers,
+ });
+ }
+ await finishExport?.();
+ return response;
+ } catch (error) {
+ await finishExport?.().catch(() => undefined);
+ const errorId = logServerError("admin.api_failed", error, {
+ path: request.nextUrl.pathname,
+ userId: context.session.user.id,
+ });
return NextResponse.json(
- { ok: false, error: `Request body exceeds ${maxBytes} bytes` },
- { status: 413 },
+ { ok: false, error: "Internal server error", errorId },
+ { status: 500 },
);
}
- }
-
- const context = await getApiAdminContext();
- if (!context)
- return NextResponse.json(
- { ok: false, error: "Unauthorized" },
- { status: 401 },
- );
- if (
- options.permission &&
- !canAccess(
- context.permissions,
- options.permission,
- context.session.user.rank,
- )
- ) {
- await logAuthorizationEvent({
- kind: "permission.denied",
- userId: context.session.user.id,
- username: context.session.user.username,
- rank: context.session.user.rank,
- permission: options.permission,
- source: request.nextUrl.pathname,
- reason: "API permission check denied",
- });
- return NextResponse.json(
- { ok: false, error: "Forbidden" },
- { status: 403 },
- );
- }
- setContextUserId(Number(context.session.user.id) as UserId);
- let finishExport: (() => Promise) | undefined;
- try {
- if (
- catalogExportEnabled() &&
- isCatalogMutation(request.method, request.nextUrl.pathname)
- ) {
- finishExport = await beginCatalogExport();
- }
- const response = await handler(request, context, routeContext);
- if (
- finishExport &&
- response.body &&
- response.headers.get("content-type")?.includes("text/event-stream")
- ) {
- const [client, completion] = response.body.tee();
- const finish = finishExport;
- after(async () => {
- const reader = completion.getReader();
- try {
- while (!(await reader.read()).done) {
- /* Wait for all import results. */
- }
- } finally {
- reader.releaseLock();
- await finish();
- }
- });
- return new Response(client, {
- status: response.status,
- headers: response.headers,
- });
- }
- await finishExport?.();
- return response;
- } catch (error) {
- await finishExport?.().catch(() => undefined);
- const errorId = logServerError("admin.api_failed", error, {
- path: request.nextUrl.pathname,
- userId: context.session.user.id,
- });
- return NextResponse.json(
- { ok: false, error: "Internal server error", errorId },
- { status: 500 },
- );
- }
- });
+ }),
+ );
+ if (store.userId !== null) {
+ recordPerformance({
+ operationId: store.requestId,
+ route: routeTemplate(
+ request.nextUrl.pathname,
+ await routeContext.params,
+ ),
+ method: request.method,
+ status: response.status,
+ at: Date.now(),
+ metrics,
+ streaming:
+ response.headers.get("content-type")?.includes("text/event-stream") ??
+ false,
+ });
+ }
const headers = new Headers(response.headers);
headers.set("x-operation-id", store.requestId);
return new Response(response.body, {
diff --git a/src/lib/db.ts b/src/lib/db.ts
index cda131e4..98e6077e 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -5,6 +5,8 @@ import * as schema from "@/db/schema";
import { env } from "@/env";
import { resolveConnectionLimit } from "@/lib/db-pool";
+import { instrumentDatabase } from "@/lib/performance-diagnostics";
+
const fullSchema = { ...schema, ...relations };
const globalForDb = globalThis as unknown as {
@@ -42,7 +44,7 @@ function createDb() {
supportBigNumbers: true,
});
- return drizzle(pool, {
+ return drizzle(instrumentDatabase(pool), {
schema: fullSchema,
mode: "default",
logger:
diff --git a/src/lib/performance-diagnostics.test.ts b/src/lib/performance-diagnostics.test.ts
new file mode 100644
index 00000000..41b4d7dd
--- /dev/null
+++ b/src/lib/performance-diagnostics.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+import {
+ collectPerformance,
+ instrumentDatabase,
+ measureDependency,
+ routeTemplate,
+} from "./performance-diagnostics";
+
+describe("performance diagnostics", () => {
+ it("isolates concurrent requests and preserves results and errors", async () => {
+ const error = new Error("private SQL parameters");
+ const [a, b] = await Promise.all([
+ collectPerformance(async () => {
+ await measureDependency("database", async () => 42);
+ await expect(
+ measureDependency("external", async () => {
+ throw error;
+ }),
+ ).rejects.toBe(error);
+ return "first";
+ }),
+ collectPerformance(async () => "second"),
+ ]);
+ expect(a.value).toBe("first");
+ expect(a.metrics.database.count).toBe(1);
+ expect(a.metrics.external.count).toBe(1);
+ expect(a.metrics.external.errors).toBe(1);
+ expect(b.metrics.database.count).toBe(0);
+ expect(JSON.stringify(a.metrics)).not.toContain("private");
+ });
+ it("measures pool and transaction queries without changing receiver or arguments", async () => {
+ const connection = {
+ query: async (sql: string) => sql,
+ release() {
+ return this;
+ },
+ };
+ const original = {
+ marker: 42,
+ async execute(value: number) {
+ return this.marker + value;
+ },
+ async getConnection() {
+ return connection;
+ },
+ };
+ const pool = instrumentDatabase(original);
+ const result = await collectPerformance(async () => {
+ expect(await pool.execute(8)).toBe(50);
+ const conn = await pool.getConnection();
+ expect(await conn.query("secret SQL")).toBe("secret SQL");
+ expect(conn.release()).toBe(connection);
+ });
+ expect(result.metrics.database.count).toBe(2);
+ expect(JSON.stringify(result.metrics)).not.toContain("secret");
+ });
+ it("removes query strings and dynamic path values including encoded catch-all segments", () => {
+ expect(
+ routeTemplate("/api/admin/users/Life?token=private", {
+ username: "Life",
+ }),
+ ).toBe("/api/admin/users/[username]");
+ expect(
+ routeTemplate("/api/admin/assets/a%20b/file.png", {
+ path: ["a b", "file.png"],
+ }),
+ ).toBe("/api/admin/assets/[path]/[path]");
+ });
+});
+
+it("does not change a completed snapshot when background work finishes", async () => {
+ let finish!: () => void;
+ let background!: Promise;
+ const result = await collectPerformance(async () => {
+ background = measureDependency(
+ "external",
+ () =>
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ });
+ expect(result.metrics.external.count).toBe(0);
+ finish();
+ await background;
+ expect(result.metrics.external.count).toBe(0);
+});
diff --git a/src/lib/performance-diagnostics.ts b/src/lib/performance-diagnostics.ts
new file mode 100644
index 00000000..5ad7938e
--- /dev/null
+++ b/src/lib/performance-diagnostics.ts
@@ -0,0 +1,86 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+
+type Dependency = "database" | "external";
+type Measurement = { count: number; durationMs: number; errors: number };
+export type PerformanceMetrics = Record & {
+ durationMs: number;
+};
+const context = new AsyncLocalStorage();
+
+export async function collectPerformance(work: () => Promise) {
+ 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) };
+}
+
+export async function measureDependency(
+ kind: Dependency,
+ work: () => Promise,
+): Promise {
+ const metrics = context.getStore();
+ if (!metrics) return work();
+ const start = performance.now();
+ const measurement = metrics[kind];
+ try {
+ return await work();
+ } catch (error) {
+ measurement.errors++;
+ throw error;
+ } finally {
+ measurement.count++;
+ measurement.durationMs += performance.now() - start;
+ }
+}
+
+/** Promise driver only. Preserve receiver, overloads and connection release behavior. */
+export function instrumentDatabase(driver: T): T {
+ return new Proxy(driver, {
+ get(target, property) {
+ const value = Reflect.get(target, property, target);
+ if (typeof value !== "function") return value;
+ if (property === "query" || property === "execute") {
+ return (...args: unknown[]) =>
+ measureDependency("database", () =>
+ Reflect.apply(value, target, args),
+ );
+ }
+ if (property === "getConnection") {
+ return async (...args: unknown[]) =>
+ instrumentDatabase(await Reflect.apply(value, target, args));
+ }
+ return value.bind(target);
+ },
+ });
+}
+
+export function routeTemplate(
+ pathname: string,
+ params: Record = {},
+) {
+ const dynamic = new Map();
+ for (const [key, value] of Object.entries(params)) {
+ for (const part of Array.isArray(value) ? value : [value])
+ dynamic.set(part, `[${key}]`);
+ }
+ return pathname
+ .split("?")[0]
+ .split("/")
+ .map((part) => {
+ let decoded = part;
+ try {
+ decoded = decodeURIComponent(part);
+ } catch {
+ return "[invalid]";
+ }
+ return dynamic.get(decoded) ?? (/^\d+$/.test(part) ? "[id]" : part);
+ })
+ .join("/")
+ .slice(0, 300);
+}
diff --git a/src/lib/performance-store.test.ts b/src/lib/performance-store.test.ts
new file mode 100644
index 00000000..36c28f4a
--- /dev/null
+++ b/src/lib/performance-store.test.ts
@@ -0,0 +1,70 @@
+import { beforeEach, expect, it, vi } from "vitest";
+
+const mock = vi.hoisted(() => ({
+ status: "ready",
+ exec: vi.fn(),
+ lrange: vi.fn(),
+}));
+vi.mock("@/lib/redis", () => ({
+ redis: {
+ get status() {
+ return mock.status;
+ },
+ multi() {
+ const chain = {
+ lpush: () => chain,
+ ltrim: () => chain,
+ expire: () => chain,
+ exec: mock.exec,
+ };
+ return chain;
+ },
+ lrange: mock.lrange,
+ },
+}));
+
+import { readPerformance, recordPerformance } from "./performance-store";
+
+const sample = {
+ operationId: "op",
+ route: "/api/admin/test",
+ method: "GET",
+ status: 200,
+ at: Date.now(),
+ streaming: false,
+ metrics: {
+ durationMs: 1200,
+ database: { count: 2, durationMs: 1000, errors: 0 },
+ external: { count: 0, durationMs: 0, errors: 0 },
+ },
+};
+beforeEach(() => {
+ mock.status = "ready";
+ mock.exec.mockResolvedValue([]);
+ mock.lrange.mockResolvedValue([]);
+});
+it("does not fail a request when storage rejects writes", async () => {
+ mock.exec.mockRejectedValue(new Error("offline"));
+ expect(() => recordPerformance(sample)).not.toThrow();
+ await Promise.resolve();
+ await Promise.resolve();
+});
+it("falls back to bounded local samples when Redis is unavailable", async () => {
+ mock.status = "end";
+ for (let n = 0; n < 250; n++)
+ recordPerformance({ ...sample, operationId: String(n) });
+ const result = await readPerformance();
+ expect(result.shared).toBe(false);
+ expect(result.samples).toHaveLength(200);
+ expect(result.samples[0].operationId).toBe("249");
+});
+it("ignores expired and malformed shared records", async () => {
+ mock.lrange.mockResolvedValue([
+ "bad",
+ JSON.stringify({ ...sample, at: 1 }),
+ JSON.stringify(sample),
+ ]);
+ const result = await readPerformance();
+ expect(result.shared).toBe(true);
+ expect(result.samples).toEqual([sample]);
+});
diff --git a/src/lib/performance-store.ts b/src/lib/performance-store.ts
new file mode 100644
index 00000000..aa48e60d
--- /dev/null
+++ b/src/lib/performance-store.ts
@@ -0,0 +1,101 @@
+import { z } from "zod";
+import type { PerformanceMetrics } from "@/lib/performance-diagnostics";
+import { redis } from "@/lib/redis";
+
+const KEY = "cms:performance:v1";
+const LIMIT = 200;
+const RETENTION_MS = 60 * 60 * 1000;
+const measurement = z.object({
+ count: z.number().nonnegative(),
+ durationMs: z.number().nonnegative(),
+ errors: z.number().nonnegative(),
+});
+const schema = z.object({
+ operationId: z.string().max(100),
+ route: z.string().max(300),
+ method: z.string().max(10),
+ status: z.number().int(),
+ at: z.number(),
+ streaming: z.boolean(),
+ metrics: z.object({
+ durationMs: z.number().nonnegative(),
+ database: measurement,
+ external: measurement,
+ }),
+});
+export interface PerformanceSample {
+ operationId: string;
+ route: string;
+ method: string;
+ status: number;
+ at: number;
+ streaming: boolean;
+ metrics: PerformanceMetrics;
+}
+const state = globalThis as typeof globalThis & {
+ cmsPerformance?: { samples: 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);
+ 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)
+ .exec()
+ .catch(() => undefined)
+ .finally(() => {
+ buffer.pending--;
+ });
+ } catch {
+ buffer.pending--;
+ }
+}
+
+export async function readPerformance(): Promise<{
+ samples: PerformanceSample[];
+ shared: boolean;
+}> {
+ const cutoff = Date.now() - RETENTION_MS;
+ if (redis?.status === "ready") {
+ let timer: ReturnType | undefined;
+ try {
+ const rows = await Promise.race([
+ redis.lrange(KEY, 0, LIMIT - 1),
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error("Diagnostic storage timeout")),
+ 1000,
+ );
+ }),
+ ]);
+ const samples = rows.flatMap((row) => {
+ try {
+ const sample = schema.safeParse(JSON.parse(row));
+ return sample.success && sample.data.at >= cutoff
+ ? [sample.data]
+ : [];
+ } catch {
+ return [];
+ }
+ });
+ return { samples, shared: true };
+ } catch {
+ /* Keep local diagnostics available during a Redis outage. */
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ return {
+ samples: buffer.samples.filter((sample) => sample.at >= cutoff),
+ shared: false,
+ };
+}
diff --git a/src/lib/services/import/core/curl-fetch.ts b/src/lib/services/import/core/curl-fetch.ts
index 6bdd181b..261282f5 100644
--- a/src/lib/services/import/core/curl-fetch.ts
+++ b/src/lib/services/import/core/curl-fetch.ts
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process";
import { existsSync, promises as fs } from "node:fs";
import { promisify } from "node:util";
+import { measureDependency } from "@/lib/performance-diagnostics";
const execFileAsync = promisify(execFile);
@@ -46,10 +47,12 @@ export async function curlFetchText(
url: string,
timeoutMs = 30_000,
): Promise {
- const { stdout } = await execFileAsync(CURL_BIN, curlArgs(url, []), {
- timeout: timeoutMs,
- maxBuffer: 512 * 1024 * 1024,
- });
+ const { stdout } = await measureDependency("external", () =>
+ execFileAsync(CURL_BIN, curlArgs(url, []), {
+ timeout: timeoutMs,
+ maxBuffer: 512 * 1024 * 1024,
+ }),
+ );
return stdout;
}
@@ -62,28 +65,30 @@ export async function curlDownload(
): Promise<{ ok: boolean; size: number }> {
try {
const cookieArgs = cookieHeader ? ["-H", `Cookie: ${cookieHeader}`] : [];
- await execFileAsync(
- CURL_BIN,
- [
- "-sSL",
- "--compressed",
- "--fail",
- "--max-time",
- "30",
- "--connect-timeout",
- "10",
- "-A",
- CURL_USER_AGENT,
- "-H",
- "Accept: application/octet-stream,*/*;q=0.9",
- "-H",
- "Accept-Language: en-US,en;q=0.9",
- ...cookieArgs,
- "-o",
- destPath,
- url,
- ],
- { timeout: timeoutMs },
+ await measureDependency("external", () =>
+ execFileAsync(
+ CURL_BIN,
+ [
+ "-sSL",
+ "--compressed",
+ "--fail",
+ "--max-time",
+ "30",
+ "--connect-timeout",
+ "10",
+ "-A",
+ CURL_USER_AGENT,
+ "-H",
+ "Accept: application/octet-stream,*/*;q=0.9",
+ "-H",
+ "Accept-Language: en-US,en;q=0.9",
+ ...cookieArgs,
+ "-o",
+ destPath,
+ url,
+ ],
+ { timeout: timeoutMs },
+ ),
);
const stat = await fs.stat(destPath);
return { ok: stat.size > 0, size: stat.size };
diff --git a/src/messages/ar.json b/src/messages/ar.json
index 067530ae..c544aed5 100644
--- a/src/messages/ar.json
+++ b/src/messages/ar.json
@@ -518,6 +518,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"myDashboard": {
diff --git a/src/messages/bg.json b/src/messages/bg.json
index 44d17933..b07bf91d 100644
--- a/src/messages/bg.json
+++ b/src/messages/bg.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/cs.json b/src/messages/cs.json
index 8ea7ffd7..f6bceec2 100644
--- a/src/messages/cs.json
+++ b/src/messages/cs.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/da.json b/src/messages/da.json
index 31c248d2..a3c3c68c 100644
--- a/src/messages/da.json
+++ b/src/messages/da.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/de.json b/src/messages/de.json
index 9bdc51c7..67717036 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -3443,6 +3443,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"radioRequests": {
diff --git a/src/messages/el.json b/src/messages/el.json
index 9894466b..e547d5a3 100644
--- a/src/messages/el.json
+++ b/src/messages/el.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/en.json b/src/messages/en.json
index 536ab934..43b21cf8 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -4357,6 +4357,29 @@
"linksInvalid": "Review the content links: only local, HTTP/HTTPS and email links are allowed.",
"schedulePast": "The selected date is in the past. Review it before publishing.",
"previewHint": "These links show the current public pages. Use Preview to check unsaved content. The final article slug may receive a suffix if already in use."
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"mod": {
diff --git a/src/messages/es.json b/src/messages/es.json
index 269f0a78..0905ad32 100644
--- a/src/messages/es.json
+++ b/src/messages/es.json
@@ -3443,6 +3443,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"radioRequests": {
diff --git a/src/messages/fi.json b/src/messages/fi.json
index 02287db2..1a1a81e8 100644
--- a/src/messages/fi.json
+++ b/src/messages/fi.json
@@ -518,6 +518,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"myDashboard": {
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 4811e7cd..677a0040 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -3443,6 +3443,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"radioRequests": {
diff --git a/src/messages/hr.json b/src/messages/hr.json
index 3ca70581..ab639925 100644
--- a/src/messages/hr.json
+++ b/src/messages/hr.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/hu.json b/src/messages/hu.json
index e6b017cb..bf0e2d58 100644
--- a/src/messages/hu.json
+++ b/src/messages/hu.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/it.json b/src/messages/it.json
index f7737f31..7998efc0 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -4332,6 +4332,29 @@
"linksInvalid": "Controlla i link del contenuto: sono ammessi link locali, HTTP/HTTPS ed email.",
"schedulePast": "La data selezionata e nel passato. Controllala prima di pubblicare.",
"previewHint": "I link mostrano le pagine pubbliche attuali. Usa Anteprima per vedere il contenuto non salvato. Lo slug finale potrebbe ricevere un suffisso se gia utilizzato."
+ },
+ "performance": {
+ "title": "Prestazioni delle richieste",
+ "scope": "API autenticate dell’HK: tempo server fino alla risposta pronta. Non misura il rendering del browser o le pagine pubbliche.",
+ "refresh": "Aggiorna",
+ "shared": "Diagnostica condivisa tramite Redis.",
+ "local": "Diagnostica locale di questa istanza; si azzera al riavvio.",
+ "retention": "Fino a 200 richieste recenti, conservate per un’ora. La raccolta può omettere misure.",
+ "search": "Percorso o ID operazione",
+ "sort": "Ordine",
+ "slowest": "Prima le più lente",
+ "recent": "Prima le più recenti",
+ "threshold": "Filtro durata",
+ "all": "Tutte le durate",
+ "slow": "Almeno 1 secondo",
+ "explanation": "Il tempo database include le query completate e le attese del pool. Il tempo esterno copre solo i download curl monitorati. Le chiamate simultanee possono sommare più della durata totale: questi valori non sono parti esclusive del totale.",
+ "empty": "Nessuna richiesta corrispondente. Usa l’HK, poi aggiorna questa pagina.",
+ "database": "Database",
+ "external": "Download monitorati",
+ "calls": "{count} chiamate",
+ "failures": "{count} errori",
+ "streaming": "Risposta in streaming: la misura termina alla creazione della risposta, non alla fine dell’operazione.",
+ "operation": "ID operazione"
}
},
"radioRequests": {
diff --git a/src/messages/ja.json b/src/messages/ja.json
index b60bdb41..613c1ae6 100644
--- a/src/messages/ja.json
+++ b/src/messages/ja.json
@@ -518,6 +518,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"myDashboard": {
diff --git a/src/messages/nl.json b/src/messages/nl.json
index 76c79b09..f3e08176 100644
--- a/src/messages/nl.json
+++ b/src/messages/nl.json
@@ -3948,6 +3948,29 @@
"linksInvalid": "Controleer de links: alleen lokale, HTTP/HTTPS- en e-maillinks zijn toegestaan.",
"schedulePast": "De geselecteerde datum ligt in het verleden. Controleer deze voor publicatie.",
"previewHint": "Deze links tonen de huidige openbare paginas. Gebruik Voorbeeld voor niet-opgeslagen inhoud. De definitieve artikelslug kan een achtervoegsel krijgen als deze al bestaat."
+ },
+ "performance": {
+ "title": "Prestaties van verzoeken",
+ "scope": "Aangemelde HK-API’s: servertijd totdat het antwoord klaarstaat. Dit meet geen browserweergave of openbare pagina’s.",
+ "refresh": "Vernieuwen",
+ "shared": "Gedeelde diagnostiek via Redis.",
+ "local": "Lokale diagnostiek voor deze instantie; gewist bij herstart.",
+ "retention": "Maximaal 200 recente verzoeken, één uur bewaard. Niet alle metingen worden gegarandeerd opgeslagen.",
+ "search": "Route of operatie-ID",
+ "sort": "Volgorde",
+ "slowest": "Langzaamste eerst",
+ "recent": "Nieuwste eerst",
+ "threshold": "Duurfilter",
+ "all": "Alle tijden",
+ "slow": "Minstens 1 seconde",
+ "explanation": "Databasetijd omvat afgeronde query’s en wachttijd op de pool. Externe tijd omvat alleen gemeten curl-downloads. Gelijktijdige oproepen kunnen samen langer duren dan het verzoek; deze waarden zijn geen afzonderlijke delen van het totaal.",
+ "empty": "Nog geen passende verzoeken. Gebruik het HK en vernieuw deze pagina.",
+ "database": "Database",
+ "external": "Gemeten downloads",
+ "calls": "{count} oproepen",
+ "failures": "{count} fouten",
+ "streaming": "Streamingantwoord: de meting stopt bij het aanmaken van het antwoord, niet aan het einde van de operatie.",
+ "operation": "Operatie-ID"
}
},
"mod": {
diff --git a/src/messages/no.json b/src/messages/no.json
index 53ccaa77..437dcc3d 100644
--- a/src/messages/no.json
+++ b/src/messages/no.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/pl.json b/src/messages/pl.json
index 5bcfdfbf..7427dee7 100644
--- a/src/messages/pl.json
+++ b/src/messages/pl.json
@@ -3473,6 +3473,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/pt.json b/src/messages/pt.json
index 4ea40e6f..92afb894 100644
--- a/src/messages/pt.json
+++ b/src/messages/pt.json
@@ -3473,6 +3473,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/ro.json b/src/messages/ro.json
index 23b535bf..e924daf5 100644
--- a/src/messages/ro.json
+++ b/src/messages/ro.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/ru.json b/src/messages/ru.json
index a82387ec..0fe5c1a9 100644
--- a/src/messages/ru.json
+++ b/src/messages/ru.json
@@ -3471,6 +3471,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/sk.json b/src/messages/sk.json
index 69e5d60a..7e521602 100644
--- a/src/messages/sk.json
+++ b/src/messages/sk.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/sr.json b/src/messages/sr.json
index 44dcc99a..836a7963 100644
--- a/src/messages/sr.json
+++ b/src/messages/sr.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/sv.json b/src/messages/sv.json
index f151929b..ab85be67 100644
--- a/src/messages/sv.json
+++ b/src/messages/sv.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/tr.json b/src/messages/tr.json
index 332ba6e7..10c97571 100644
--- a/src/messages/tr.json
+++ b/src/messages/tr.json
@@ -3473,6 +3473,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {
diff --git a/src/messages/uk.json b/src/messages/uk.json
index f3b17331..e7c059c7 100644
--- a/src/messages/uk.json
+++ b/src/messages/uk.json
@@ -3472,6 +3472,29 @@
"lastAttempt": "Last attempt: {date}",
"queuing": "Queuing…",
"queue": "Export now / retry"
+ },
+ "performance": {
+ "title": "Request performance",
+ "scope": "Authenticated HK APIs: server time until the response is ready. This does not measure browser rendering or public pages.",
+ "refresh": "Refresh",
+ "shared": "Shared diagnostics via Redis.",
+ "local": "Local diagnostics for this instance only; cleared on restart.",
+ "retention": "Up to 200 recent requests, retained for one hour. Collection is best effort.",
+ "search": "Route or operation ID",
+ "sort": "Order",
+ "slowest": "Slowest first",
+ "recent": "Most recent first",
+ "threshold": "Duration filter",
+ "all": "All durations",
+ "slow": "At least 1 second",
+ "explanation": "Database time includes completed queries and pool waits. External time covers monitored curl downloads only. Concurrent calls can add up to more than the request duration; these values are not exclusive parts of the total.",
+ "empty": "No matching requests yet. Use the HK, then refresh this page.",
+ "database": "Database",
+ "external": "Monitored downloads",
+ "calls": "{count} calls",
+ "failures": "{count} failures",
+ "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.",
+ "operation": "Operation ID"
}
},
"error": {