Files
HealthLog/src/lib/logging/in-memory-buffer.ts
T
Marc-André BombeckandClaude Opus 4.7 a66c128dd9 feat(insights): citation footnote on recommendations with medical reference
When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.

`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).

i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.

6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:04:24 +02:00

94 lines
3.4 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.
* - 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;
// Plain array with FIFO eviction. A linked list would be technically tighter
// but for 500 entries the array shift overhead (~microseconds) is invisible
// next to the rest of the request lifecycle.
let buffer: WideEvent[] = [];
/** Append an event to the buffer; evict the oldest entry once cap is hit. */
export function appendLogEvent(event: WideEvent): void {
buffer.push(event);
if (buffer.length > LOG_BUFFER_MAX) {
buffer.shift();
}
}
export interface ReadLogBufferOptions {
/** 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();
// Walk back-to-front so the result is newest-first without an extra reverse.
const out: WideEvent[] = [];
for (let i = buffer.length - 1; i >= 0; i--) {
const event = buffer[i];
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 = [];
}
/** Current buffer size (test-only). */
export function getLogBufferSize(): number {
return buffer.length;
}