feat(devops): diagnose slow HK requests and dependency timings
This commit is contained in:
1 parent
3b76d063a6
commit
00e33623f7
42 files changed
+1335
-132
No files matched your search
@@ -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.
|
||||
@@ -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() {
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{installation("title")}
|
||||
</Link>
|
||||
<Link href="/admin/devops/performance" className="btn btn-outline">
|
||||
{performance("title")}
|
||||
</Link>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<Record<string, string>>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("scope")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/admin/devops" className="btn btn-outline">
|
||||
DevOps
|
||||
</Link>
|
||||
<Link href="/admin/devops/performance" className="btn btn-outline">
|
||||
{t("refresh")}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="admin-card text-sm">
|
||||
{t(shared ? "shared" : "local")} {t("retention")}
|
||||
</p>
|
||||
<form className="admin-card flex flex-wrap gap-3">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
aria-label={t("search")}
|
||||
placeholder={t("search")}
|
||||
className="input min-w-0 flex-1"
|
||||
maxLength={100}
|
||||
/>
|
||||
<select
|
||||
name="sort"
|
||||
defaultValue={params.sort === "recent" ? "recent" : "slowest"}
|
||||
aria-label={t("sort")}
|
||||
className="input"
|
||||
>
|
||||
<option value="slowest">{t("slowest")}</option>
|
||||
<option value="recent">{t("recent")}</option>
|
||||
</select>
|
||||
<select
|
||||
name="slow"
|
||||
defaultValue={params.slow === "1" ? "1" : "0"}
|
||||
aria-label={t("threshold")}
|
||||
className="input"
|
||||
>
|
||||
<option value="0">{t("all")}</option>
|
||||
<option value="1">{t("slow")}</option>
|
||||
</select>
|
||||
<button className="btn btn-primary" type="submit">
|
||||
{t("search")}
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">{t("explanation")}</p>
|
||||
{visible.length === 0 ? (
|
||||
<p className="admin-card">{t("empty")}</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{visible.map((sample) => (
|
||||
<li
|
||||
key={sample.operationId}
|
||||
className="admin-card min-w-0 space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="break-all font-medium">
|
||||
{sample.method} {sample.route}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(sample.at).toISOString()} · HTTP {sample.status}
|
||||
</p>
|
||||
</div>
|
||||
<strong
|
||||
className={
|
||||
sample.metrics.durationMs >= 1000 ? "text-destructive" : ""
|
||||
}
|
||||
>
|
||||
{Math.round(sample.metrics.durationMs)} ms
|
||||
</strong>
|
||||
</div>
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt>{t("database")}</dt>
|
||||
<dd>
|
||||
{Math.round(sample.metrics.database.durationMs)} ms ·{" "}
|
||||
{t("calls", { count: sample.metrics.database.count })} ·{" "}
|
||||
{t("failures", { count: sample.metrics.database.errors })}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("external")}</dt>
|
||||
<dd>
|
||||
{Math.round(sample.metrics.external.durationMs)} ms ·{" "}
|
||||
{t("calls", { count: sample.metrics.external.count })} ·{" "}
|
||||
{t("failures", { count: sample.metrics.external.errors })}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{sample.streaming && (
|
||||
<p className="text-sm text-destructive">{t("streaming")}</p>
|
||||
)}
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{t("operation")}: {sample.operationId}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+120
-97
@@ -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<void>) | 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<void>) | 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, {
|
||||
|
||||
+3
-1
@@ -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:
|
||||
|
||||
@@ -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<void>;
|
||||
const result = await collectPerformance(async () => {
|
||||
background = measureDependency(
|
||||
"external",
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(result.metrics.external.count).toBe(0);
|
||||
finish();
|
||||
await background;
|
||||
expect(result.metrics.external.count).toBe(0);
|
||||
});
|
||||
@@ -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<Dependency, Measurement> & {
|
||||
durationMs: number;
|
||||
};
|
||||
const context = new AsyncLocalStorage<PerformanceMetrics>();
|
||||
|
||||
export async function collectPerformance<T>(work: () => Promise<T>) {
|
||||
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<T>(
|
||||
kind: Dependency,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<T extends object>(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<string, string | string[]> = {},
|
||||
) {
|
||||
const dynamic = new Map<string, string>();
|
||||
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);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const rows = await Promise.race([
|
||||
redis.lrange(KEY, 0, LIMIT - 1),
|
||||
new Promise<never>((_, 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,
|
||||
};
|
||||
}
|
||||
@@ -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<string> {
|
||||
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 };
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user