Files
HealthLog/src/lib/logging/transports.ts
T
Marc-André BombeckandClaude Opus 4.7 4cc3d8d45e feat(admin): app-log preview surfaces last 1h of structured events with filter + JSON inspector
Wires the in-memory ring buffer (`src/lib/logging/in-memory-buffer.ts`)
into `transports.emitEvent()` so every wide event that survives the
sampler also lands in a 500-entry per-process buffer. Adds the
`app-logs` admin section slug, the renderer case, and the route entry
exports needed for the new section component to mount.

The buffer is volatile and per-process — a worker-only event won't
show up in the web buffer, and a restart drops everything. The section
header copy spells that out so admins know to keep Loki configured for
durable diagnostics. Egress through `/api/admin/app-logs` runs every
event through `redactSecrets()` so a stray Bearer / hlk_ / sk- token
in an `error.message` never leaks to the admin UI; storage stays raw
to keep the diagnostic value when shipped to Loki.

The companion buffer + transports tests live under
`src/lib/logging/__tests__/in-memory-buffer.test.ts` and
`transports-buffer.test.ts` (committed separately as part of the
buffer scaffolding). The route-level tests in
`src/app/api/admin/app-logs/__tests__/route.test.ts` cover newest-
first ordering, traceId / level / action filters, the `limit` cap,
secret redaction on egress, and the Zod-rejected invalid `level` case.

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

115 lines
3.2 KiB
TypeScript

import type { WideEvent } from "./types";
import { getLoggingConfig } from "./config";
import { shouldEmit } from "./sampler";
import { appendLogEvent } from "./in-memory-buffer";
/** Event auf stdout als einzelne JSON-Zeile schreiben */
function emitToStdout(event: WideEvent): void {
const config = getLoggingConfig();
const json = config.prettyPrint
? JSON.stringify(event, null, 2)
: JSON.stringify(event);
process.stdout.write(json + "\n");
}
// Loki Push API Buffer
const LOKI_MAX_BUFFER_SIZE = 1000;
let lokiBuffer: WideEvent[] = [];
let lokiFlushTimer: ReturnType<typeof setInterval> | null = null;
function initLokiTransport(): void {
const config = getLoggingConfig();
if (!config.lokiEndpoint || lokiFlushTimer) return;
lokiFlushTimer = setInterval(() => {
flushLokiBuffer().catch((err) => {
process.stderr.write(`[logging] Loki flush error: ${err}\n`);
});
}, 5000);
if (lokiFlushTimer.unref) lokiFlushTimer.unref();
}
async function flushLokiBuffer(): Promise<void> {
if (lokiBuffer.length === 0) return;
const config = getLoggingConfig();
if (!config.lokiEndpoint) return;
const batch = lokiBuffer;
lokiBuffer = [];
const streams = [
{
stream: {
service: "healthlog",
environment: batch[0]?.environment || "production",
},
values: batch.map((event) => [
// Loki erwartet Nanosekunden-Timestamp als String
new Date(event.timestamp).getTime() + "000000",
JSON.stringify(event),
]),
},
];
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.lokiUsername && config.lokiPassword) {
headers["Authorization"] =
"Basic " +
Buffer.from(`${config.lokiUsername}:${config.lokiPassword}`).toString(
"base64",
);
}
try {
await fetch(`${config.lokiEndpoint}/loki/api/v1/push`, {
method: "POST",
headers,
body: JSON.stringify({ streams }),
signal: AbortSignal.timeout(10_000),
});
} catch {
// Events gehen verloren — akzeptabel fuer Logging
}
}
/**
* Event emittieren falls Sampling-Kriterien erfuellt.
* Zentraler Einstiegspunkt — entfernt Stack Traces falls konfiguriert.
*/
export function emitIfSampled(event: WideEvent): void {
const config = getLoggingConfig();
if (!config.includeStackTrace && event.error?.stack) {
delete event.error.stack;
}
if (shouldEmit(event)) {
emitEvent(event);
}
}
/** Zentraler Emit: stdout + optional Loki-Buffer + in-memory ring buffer */
export function emitEvent(event: WideEvent): void {
emitToStdout(event);
// Push into the per-process in-memory ring buffer so admins can drill
// into the most recent ~500 wide events from the `/admin/app-logs`
// page without standing up a Loki stack. See `in-memory-buffer.ts`.
// Wrapped in try/catch so a buffer bug never poisons the request.
try {
appendLogEvent(event);
} catch {
/* logging must never crash the handler */
}
const config = getLoggingConfig();
if (config.lokiEndpoint) {
initLokiTransport();
if (lokiBuffer.length >= LOKI_MAX_BUFFER_SIZE) {
lokiBuffer.shift();
}
lokiBuffer.push(event);
}
}