aiostreams / packages /core /src /logging /ring-buffer.ts
f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
5.93 kB
import { EventEmitter } from 'events';
import { bootstrap } from '../config/bootstrap.js';
/**
* One retained log record. `line` is the raw NDJSON string pino already
* produced (already redacted — see `redact.ts`). `ts`/`level`/`module` are
* pulled out once, in the ring writer, so the dashboard can filter without
* re-parsing every line. `seq` is a monotonic cursor used for SSE resume.
*/
export interface LogRecord {
seq: number;
ts: number;
level: string;
module?: string;
line: string;
}
export interface LogQuery {
q?: string;
regex?: boolean;
levels?: string[];
modules?: string[];
since?: number;
until?: number;
limit?: number;
order?: 'asc' | 'desc';
}
const LEVEL_NAMES = new Set([
'trace',
'debug',
'info',
'warn',
'error',
'fatal',
]);
/**
* Process-singleton in-memory ring of recent log lines. Backs the dashboard
* Logs page. Byte-bounded so memory stays predictable regardless of how
* chatty logging is; a hard entry cap is a secondary safety net.
*
* The hot path is `push()` — a single `JSON.parse` (to extract ts/level/
* module) plus an array append and amortised batched eviction. This runs on
* the pino stream, never on the request path.
*/
class LogRingBuffer {
private readonly maxBytes: number;
private readonly maxEntries: number;
private buf: LogRecord[] = [];
private head = 0; // index of the oldest live record in `buf`
private bytes = 0;
private seq = 0;
/** Emits `'line'` with a {@link LogRecord} for SSE subscribers. */
readonly bus = new EventEmitter();
constructor(maxBytes: number, maxEntries: number) {
this.maxBytes = Math.max(1, maxBytes);
this.maxEntries = Math.max(1, maxEntries);
this.bus.setMaxListeners(0);
}
push(line: string): void {
let ts = Date.now();
let level = 'info';
let module: string | undefined;
try {
const o = JSON.parse(line) as Record<string, unknown>;
if (typeof o.level === 'string' && LEVEL_NAMES.has(o.level)) {
level = o.level;
}
if (typeof o.module === 'string') module = o.module;
if (typeof o.time === 'string') {
const parsed = Date.parse(o.time);
if (!Number.isNaN(parsed)) ts = parsed;
} else if (typeof o.time === 'number') {
ts = o.time;
}
} catch {
// Non-JSON line (shouldn't happen — pino emits NDJSON). Keep it anyway.
}
const rec: LogRecord = {
seq: ++this.seq,
ts,
level,
module,
line,
};
this.buf.push(rec);
this.bytes += Buffer.byteLength(line, 'utf8');
this.evict();
this.bus.emit('line', rec);
}
private evict(): void {
while (
this.buf.length - this.head > this.maxEntries ||
(this.bytes > this.maxBytes && this.buf.length - this.head > 1)
) {
const dropped = this.buf[this.head];
this.bytes -= Buffer.byteLength(dropped.line, 'utf8');
this.head++;
}
// Compact the dead prefix in batches so eviction stays amortised O(1).
if (this.head > 4096 && this.head * 2 > this.buf.length) {
this.buf = this.buf.slice(this.head);
this.head = 0;
}
}
/**
* Build a text-match predicate from `query.q` / `query.regex`. Called once
* per query so regex compilation and string lowercasing happen only once
* regardless of how many records are scanned.
*/
private buildTextMatcher(
query: LogQuery
): ((line: string) => boolean) | null {
if (!query.q) return null;
if (query.regex) {
try {
const re = new RegExp(query.q, 'i');
return (line) => re.test(line);
} catch {
return () => false;
}
}
const lower = query.q.toLowerCase();
return (line) => line.toLowerCase().includes(lower);
}
private matches(
rec: LogRecord,
query: LogQuery,
textMatcher: ((line: string) => boolean) | null
): boolean {
if (
query.levels &&
query.levels.length &&
!query.levels.includes(rec.level)
)
return false;
if (
query.modules &&
query.modules.length &&
(!rec.module || !query.modules.includes(rec.module))
)
return false;
if (query.since !== undefined && rec.seq <= query.since) return false;
if (query.until !== undefined && rec.ts > query.until) return false;
if (textMatcher && !textMatcher(rec.line)) return false;
return true;
}
/** Used by the SSE endpoint to apply the same filters as the page. */
test(rec: LogRecord, query: LogQuery): boolean {
return this.matches(rec, query, this.buildTextMatcher(query));
}
query(query: LogQuery): { records: LogRecord[]; nextSeq: number } {
const limit = Math.min(Math.max(query.limit ?? 1000, 1), 5000);
const order = query.order ?? 'desc';
const out: LogRecord[] = [];
const textMatcher = this.buildTextMatcher(query);
// Walk newest → oldest so `limit` keeps the most recent matches.
for (let i = this.buf.length - 1; i >= this.head; i--) {
const rec = this.buf[i];
if (this.matches(rec, query, textMatcher)) {
out.push(rec);
if (out.length >= limit) break;
}
}
if (order === 'asc') out.reverse();
return { records: out, nextSeq: this.seq };
}
/** Iterate matching records oldest → newest without building one big array (export). */
*iterate(query: LogQuery): Generator<LogRecord> {
const textMatcher = this.buildTextMatcher(query);
for (let i = this.head; i < this.buf.length; i++) {
const rec = this.buf[i];
if (this.matches(rec, query, textMatcher)) yield rec;
}
}
stats() {
return {
entries: this.buf.length - this.head,
bytes: this.bytes,
maxBytes: this.maxBytes,
maxEntries: this.maxEntries,
lastSeq: this.seq,
};
}
}
export const logRingBuffer = new LogRingBuffer(
bootstrap.logBufferMaxBytes,
bootstrap.logBufferMaxEntries
);