The server now logs polled reads at DEBUG, and the browser's copy of each request line promises the server's level: at INFO the unread-count polls filled My log within minutes and, at Detailed, the shipped log. The server marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list stays in one place.
109 lines
4.3 KiB
TypeScript
109 lines
4.3 KiB
TypeScript
// The ONE place the app calls `fetch` (lint-enforced: eslint.config.js). Every request gets an
|
|
// `X-Request-Id`, and every answer — or network failure — becomes one `http.request` event in the
|
|
// client log, the browser's copy of the server's access line with the same id.
|
|
//
|
|
// Deliberately nothing else: no error dialog, no connection notice, no account header. `req` and
|
|
// `beacon` (lib/api/core.ts) add the app's policy on top; the public pages (/watch, the legal pages)
|
|
// use this directly, because they render outside the app and must not pull its notification and
|
|
// dialog machinery into their chunks.
|
|
import { log, newRequestId, type Level } from "./log";
|
|
|
|
/** The route TEMPLATE the server matched (`/api/videos/{video_id}`), which it sends back in `X-Route`.
|
|
* Grouping by it is what makes a client line comparable, and it is safe by construction: a token in
|
|
* the path (`/api/public/watch/{token}`) arrives already replaced by its parameter name. */
|
|
export const ROUTE_HEADER = "X-Route";
|
|
/** Set when the server logged this request at DEBUG (a polled read, the log console's own reads): the
|
|
* browser's copy of the line goes to DEBUG too, from the server's one list (`logs._POLLED_ROUTES`). */
|
|
export const QUIET_HEADER = "X-Log-Quiet";
|
|
export const REQUEST_ID_HEADER = "X-Request-Id";
|
|
|
|
// A static path word: kept. Anything else (an id, a channel id, a share token) becomes `{}`.
|
|
const STATIC_SEGMENT = /^[a-z]+(?:[-_.][a-z]+)*$/;
|
|
|
|
/** The path to log when the server named no template (a network failure, a gateway's own answer):
|
|
* the path alone, with every segment that is not a plain lowercase word replaced — never a query,
|
|
* never an id or a token. */
|
|
export function pathTemplate(url: string): string {
|
|
const path = url.split(/[?#]/, 1)[0] ?? "";
|
|
return path
|
|
.split("/")
|
|
.map((seg) => (seg === "" || STATIC_SEGMENT.test(seg) ? seg : "{}"))
|
|
.join("/");
|
|
}
|
|
|
|
/** Level of a request line — the server's own rule (`logs.log_requests`), so both sides agree on
|
|
* what a 404 is, and on which successes are too routine for INFO (`quiet`, the server's say). */
|
|
function statusLevel(status: number, quiet: boolean): Level {
|
|
return status >= 500 ? "ERROR" : status >= 400 ? "WARNING" : quiet ? "DEBUG" : "INFO";
|
|
}
|
|
|
|
/** Record one request's outcome. `route` is the server's template when it sent one. */
|
|
export function logRequest(
|
|
requestId: string,
|
|
method: string,
|
|
url: string,
|
|
started: number,
|
|
outcome:
|
|
| { status: number; route?: string | null; quiet?: boolean }
|
|
| { failed: "network" | "aborted" },
|
|
extra: Record<string, unknown> = {},
|
|
): void {
|
|
const duration_ms = Math.round((performance.now() - started) * 10) / 10;
|
|
if ("failed" in outcome) {
|
|
const path = pathTemplate(url);
|
|
log.request(
|
|
outcome.failed === "aborted" ? "INFO" : "WARNING",
|
|
`${method} ${path} ${outcome.failed}`,
|
|
{ method, path, failed: outcome.failed, duration_ms, ...extra },
|
|
requestId,
|
|
);
|
|
return;
|
|
}
|
|
const path = outcome.route || pathTemplate(url);
|
|
log.request(
|
|
statusLevel(outcome.status, outcome.quiet === true),
|
|
`${method} ${path} ${outcome.status}`,
|
|
{ method, path, status: outcome.status, duration_ms, ...extra },
|
|
requestId,
|
|
);
|
|
}
|
|
|
|
function headerOf(r: Response, name: string): string | null {
|
|
// Test doubles hand back plain objects; a real Response always has Headers.
|
|
return typeof r.headers?.get === "function" ? r.headers.get(name) : null;
|
|
}
|
|
|
|
/** `fetch`, with a request id and one log event. Rejects exactly as `fetch` does. */
|
|
export async function send(
|
|
url: string,
|
|
init: RequestInit = {},
|
|
extra: Record<string, unknown> = {},
|
|
): Promise<Response> {
|
|
const requestId = newRequestId();
|
|
const method = (init.method ?? "GET").toUpperCase();
|
|
const headers = new Headers(init.headers);
|
|
headers.set(REQUEST_ID_HEADER, requestId);
|
|
const started = performance.now();
|
|
let r: Response;
|
|
try {
|
|
r = await fetch(url, { ...init, headers });
|
|
} catch (e) {
|
|
const aborted = e instanceof DOMException && e.name === "AbortError";
|
|
logRequest(requestId, method, url, started, { failed: aborted ? "aborted" : "network" }, extra);
|
|
throw e;
|
|
}
|
|
logRequest(
|
|
requestId,
|
|
method,
|
|
url,
|
|
started,
|
|
{
|
|
status: r.status,
|
|
route: headerOf(r, ROUTE_HEADER),
|
|
quiet: headerOf(r, QUIET_HEADER) === "1",
|
|
},
|
|
extra,
|
|
);
|
|
return r;
|
|
}
|