From 00e33623f78ccc57584420902d972e47d3b955e9 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 09:48:19 +0200 Subject: [PATCH] feat(devops): diagnose slow HK requests and dependency timings --- docs/operations/cms-error-center.md | 20 ++ src/app/admin/devops/page.tsx | 4 + src/app/admin/devops/performance/page.test.ts | 77 +++++++ src/app/admin/devops/performance/page.tsx | 134 +++++++++++ .../foundation-source-contract.test.ts | 6 +- .../migration/discover-legacy-pages.test.ts | 2 +- .../housekeeping/migration/matrix.test.ts | 4 +- .../housekeeping/migration/system.test.ts | 4 +- src/features/housekeeping/migration/system.ts | 12 + src/lib/api-handler-correlation.test.ts | 7 + src/lib/api-handler.ts | 217 ++++++++++-------- src/lib/db.ts | 4 +- src/lib/performance-diagnostics.test.ts | 87 +++++++ src/lib/performance-diagnostics.ts | 86 +++++++ src/lib/performance-store.test.ts | 70 ++++++ src/lib/performance-store.ts | 101 ++++++++ src/lib/services/import/core/curl-fetch.ts | 57 ++--- src/messages/ar.json | 23 ++ src/messages/bg.json | 23 ++ src/messages/cs.json | 23 ++ src/messages/da.json | 23 ++ src/messages/de.json | 23 ++ src/messages/el.json | 23 ++ src/messages/en.json | 23 ++ src/messages/es.json | 23 ++ src/messages/fi.json | 23 ++ src/messages/fr.json | 23 ++ src/messages/hr.json | 23 ++ src/messages/hu.json | 23 ++ src/messages/it.json | 23 ++ src/messages/ja.json | 23 ++ src/messages/nl.json | 23 ++ src/messages/no.json | 23 ++ src/messages/pl.json | 23 ++ src/messages/pt.json | 23 ++ src/messages/ro.json | 23 ++ src/messages/ru.json | 23 ++ src/messages/sk.json | 23 ++ src/messages/sr.json | 23 ++ src/messages/sv.json | 23 ++ src/messages/tr.json | 23 ++ src/messages/uk.json | 23 ++ 42 files changed, 1335 insertions(+), 132 deletions(-) create mode 100644 src/app/admin/devops/performance/page.test.ts create mode 100644 src/app/admin/devops/performance/page.tsx create mode 100644 src/lib/performance-diagnostics.test.ts create mode 100644 src/lib/performance-diagnostics.ts create mode 100644 src/lib/performance-store.test.ts create mode 100644 src/lib/performance-store.ts 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")}

+ ) : ( +
    + {visible.map((sample) => ( +
  • +
    +
    +

    + {sample.method} {sample.route} +

    +

    + {new Date(sample.at).toISOString()} · HTTP {sample.status} +

    +
    + = 1000 ? "text-destructive" : "" + } + > + {Math.round(sample.metrics.durationMs)} ms + +
    +
    +
    +
    {t("database")}
    +
    + {Math.round(sample.metrics.database.durationMs)} ms ·{" "} + {t("calls", { count: sample.metrics.database.count })} ·{" "} + {t("failures", { count: sample.metrics.database.errors })} +
    +
    +
    +
    {t("external")}
    +
    + {Math.round(sample.metrics.external.durationMs)} ms ·{" "} + {t("calls", { count: sample.metrics.external.count })} ·{" "} + {t("failures", { count: sample.metrics.external.errors })} +
    +
    +
    + {sample.streaming && ( +

    {t("streaming")}

    + )} +

    + {t("operation")}: {sample.operationId} +

    +
  • + ))} +
+ )} +
+ ); +} 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": {