mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
/**
|
|
* Per-process in-memory ring buffer for the most recent wide events.
|
|
*
|
|
* Used by `/admin/app-logs` so admins can drill into structured wide-events
|
|
* without standing up a Loki stack just to read the last hour of traffic.
|
|
* Events are pushed by `transports.emitEvent()` after they pass the sampler
|
|
* gate (so the buffer mirrors what a Loki sink would receive, not the raw
|
|
* pre-sampling firehose).
|
|
*
|
|
* Limitations:
|
|
* - Per-process. Under `HEALTHLOG_PROCESS_TYPE=web,worker` split, the
|
|
* web buffer doesn't see worker events and vice-versa. The UI surfaces
|
|
* this caveat in the section header.
|
|
* - 500-entry cap (~500KB max @ ~1KB/event). FIFO eviction on overflow.
|
|
* - A second ring, `problems`, keeps the last 1,000 `warn` and `error`
|
|
* events only. On a busy host the 500 entries of the main ring turn over
|
|
* in minutes of ordinary traffic, so the one failure a person reports
|
|
* was gone by the time the operator looked for it. Info traffic never
|
|
* enters the problems ring and cannot evict from it.
|
|
* - Volatile — restart drops the buffer. For durable storage configure
|
|
* `LOKI_ENDPOINT` and tail Loki instead.
|
|
*
|
|
* Privacy: events stored raw. The API render path (`/api/admin/app-logs`)
|
|
* runs `redactSecrets()` on egress so a stray Bearer token in an error
|
|
* message never leaks to the admin UI.
|
|
*/
|
|
import type { WideEvent, LogLevel } from "./types";
|
|
|
|
export const LOG_BUFFER_MAX = 500;
|
|
|
|
/** Capacity of the warn/error-only ring. */
|
|
export const PROBLEMS_BUFFER_MAX = 1000;
|
|
|
|
/** Which ring a read walks: every event, or only `warn` and `error`. */
|
|
export type LogRing = "all" | "problems";
|
|
|
|
// Plain arrays with FIFO eviction. A linked list would be technically tighter
|
|
// but for 1,000 entries the array shift overhead (~microseconds) is invisible
|
|
// next to the rest of the request lifecycle.
|
|
let buffer: WideEvent[] = [];
|
|
let problems: WideEvent[] = [];
|
|
|
|
/** Whether an event belongs in the problems ring. */
|
|
function isProblem(event: WideEvent): boolean {
|
|
return event.level === "warn" || event.level === "error";
|
|
}
|
|
|
|
/**
|
|
* Append an event to the main ring and, for `warn` and `error`, to the
|
|
* problems ring; each evicts its oldest entry once its cap is hit. The same
|
|
* object sits in both, so an event costs its memory once.
|
|
*/
|
|
export function appendLogEvent(event: WideEvent): void {
|
|
buffer.push(event);
|
|
if (buffer.length > LOG_BUFFER_MAX) {
|
|
buffer.shift();
|
|
}
|
|
if (isProblem(event)) {
|
|
problems.push(event);
|
|
if (problems.length > PROBLEMS_BUFFER_MAX) {
|
|
problems.shift();
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface ReadLogBufferOptions {
|
|
/** The ring to read; `all` when absent. */
|
|
ring?: LogRing;
|
|
/**
|
|
* Prefix match on `event.request_id`, case-insensitive. A person reads out
|
|
* the first 8 characters of an error id; the full id matches too.
|
|
*/
|
|
requestId?: string;
|
|
/** Exact match on `event.trace_id`. */
|
|
traceId?: string;
|
|
/** Exact match on `event.level`. */
|
|
level?: LogLevel;
|
|
/** Case-insensitive substring match on `event.action.name`. */
|
|
action?: string;
|
|
/** Inclusive lower bound on `event.timestamp`. */
|
|
since?: Date;
|
|
/** Inclusive upper bound on `event.timestamp`. */
|
|
until?: Date;
|
|
/** Cap the number of returned entries. */
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Read the buffer with optional filters. Results are returned newest-first
|
|
* (descending by insertion order, which matches `event.timestamp` since we
|
|
* push at request-finish time).
|
|
*/
|
|
export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] {
|
|
const sinceMs = opts.since?.getTime();
|
|
const untilMs = opts.until?.getTime();
|
|
const actionLower = opts.action?.toLowerCase();
|
|
const requestIdLower = opts.requestId?.toLowerCase();
|
|
const source = opts.ring === "problems" ? problems : buffer;
|
|
|
|
// Walk back-to-front so the result is newest-first without an extra reverse.
|
|
const out: WideEvent[] = [];
|
|
for (let i = source.length - 1; i >= 0; i--) {
|
|
const event = source[i];
|
|
if (
|
|
requestIdLower &&
|
|
!(event.request_id ?? "").toLowerCase().startsWith(requestIdLower)
|
|
) {
|
|
continue;
|
|
}
|
|
if (opts.traceId && event.trace_id !== opts.traceId) continue;
|
|
if (opts.level && event.level !== opts.level) continue;
|
|
if (actionLower) {
|
|
const name = event.action?.name?.toLowerCase() ?? "";
|
|
if (!name.includes(actionLower)) continue;
|
|
}
|
|
if (sinceMs !== undefined || untilMs !== undefined) {
|
|
const ts = Date.parse(event.timestamp);
|
|
if (sinceMs !== undefined && ts < sinceMs) continue;
|
|
if (untilMs !== undefined && ts > untilMs) continue;
|
|
}
|
|
out.push(event);
|
|
if (opts.limit !== undefined && out.length >= opts.limit) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Reset the buffer (test-only — not exported from the package index). */
|
|
export function clearLogBuffer(): void {
|
|
buffer = [];
|
|
problems = [];
|
|
}
|
|
|
|
/** Current buffer size (test-only). */
|
|
export function getLogBufferSize(ring: LogRing = "all"): number {
|
|
return ring === "problems" ? problems.length : buffer.length;
|
|
}
|