51b160bfc9
A line identical to the last persisted row (level+source+message+path) within a 5-min refreshing window updates that row — context._repeat counts the fold, _firstAt keeps the first occurrence, createdAt tracks the latest so the storm stays at the top of the newest-first viewer. A continuous storm stays ONE row however long it rages, so it can't evict unrelated history via the 50k row cap or grind the appliance disk. LogsViewer badges coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory last-row cache only; a pruned-under-us row falls through to a fresh insert. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
117 lines
4.7 KiB
TypeScript
117 lines
4.7 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { appLogs, type Db } from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { LogService, pinoDbStream } from "./log-service.js";
|
|
|
|
// pinoDbStream feeds backend warn+ lines into app_logs. Since 2026-07-04 the logger
|
|
// emits level NAMES ("warn") instead of pino's numeric codes (40) — for human-readable
|
|
// container logs — and the stream must accept BOTH encodings (numeric covers any
|
|
// default-configured pino). A level the tee can't resolve falls back to info → not
|
|
// persisted, never a crash.
|
|
|
|
let db: Db;
|
|
let stream: { write: (line: string) => void };
|
|
let teed: string[];
|
|
|
|
beforeEach(() => {
|
|
({ db } = createTestDb());
|
|
teed = [];
|
|
stream = pinoDbStream(new LogService(db), {
|
|
write: (line: string) => {
|
|
teed.push(line);
|
|
return true;
|
|
},
|
|
} as unknown as NodeJS.WritableStream);
|
|
});
|
|
|
|
const rows = () => db.select().from(appLogs).all();
|
|
|
|
describe("pinoDbStream level encodings", () => {
|
|
it("persists a LABEL-level warn line (the current logger format)", () => {
|
|
stream.write(`{"level":"warn","time":"2026-07-04T18:14:11.453Z","msg":"label warn"}\n`);
|
|
expect(rows()).toHaveLength(1);
|
|
expect(rows()[0]).toMatchObject({ level: "warn", source: "backend", message: "label warn" });
|
|
});
|
|
|
|
it("still persists a NUMERIC-level error line (legacy/default pino)", () => {
|
|
stream.write(`{"level":50,"time":1783179038453,"msg":"numeric error"}\n`);
|
|
expect(rows()[0]).toMatchObject({ level: "error", message: "numeric error" });
|
|
});
|
|
|
|
it("info stays stdout-only in both encodings (teed, not persisted)", () => {
|
|
stream.write(`{"level":"info","msg":"label info"}\n`);
|
|
stream.write(`{"level":30,"msg":"numeric info"}\n`);
|
|
expect(rows()).toHaveLength(0);
|
|
expect(teed).toHaveLength(2); // stdout tee always happens
|
|
});
|
|
|
|
it("an unresolvable level falls back to info (dropped), never throws", () => {
|
|
stream.write(`{"level":"loud","msg":"weird"}\n`);
|
|
stream.write(`not json at all\n`);
|
|
expect(rows()).toHaveLength(0);
|
|
expect(teed).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// Storm coalescing: a line identical to the LAST persisted row (level+source+message+
|
|
// path), arriving within 5 min of its previous occurrence, UPDATES that row (bumping
|
|
// context._repeat) instead of inserting — one screaming device can't evict unrelated
|
|
// history. The row's createdAt tracks the LATEST occurrence; the first is preserved in
|
|
// context._firstAt.
|
|
describe("storm coalescing", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("folds a burst of identical error lines into ONE row with a repeat counter", () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
stream.write(`{"level":"error","msg":"button-light setAux failed (ctl R3): send ENETUNREACH"}\n`);
|
|
}
|
|
const all = rows();
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0].context).toMatchObject({ _repeat: 200 });
|
|
expect(teed).toHaveLength(200); // stdout still gets every line
|
|
});
|
|
|
|
it("keeps first-occurrence time in _firstAt while createdAt tracks the latest", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
|
vi.setSystemTime(new Date("2026-07-08T10:02:00.000Z"));
|
|
stream.write(`{"level":"warn","msg":"same"}\n`);
|
|
const [row] = rows();
|
|
expect(row.createdAt).toBe("2026-07-08T10:02:00.000Z");
|
|
expect(row.context).toMatchObject({ _repeat: 2, _firstAt: "2026-07-08T10:00:00.000Z" });
|
|
});
|
|
|
|
it("a different message (or level) breaks the run — separate rows", () => {
|
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
|
stream.write(`{"level":"error","msg":"boom A"}\n`);
|
|
stream.write(`{"level":"error","msg":"boom B"}\n`);
|
|
stream.write(`{"level":"warn","msg":"boom B"}\n`);
|
|
expect(rows()).toHaveLength(3);
|
|
});
|
|
|
|
it("an occurrence past the 5-minute window starts a fresh row", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-08T10:00:00.000Z"));
|
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
|
vi.setSystemTime(new Date("2026-07-08T10:06:00.000Z"));
|
|
stream.write(`{"level":"error","msg":"slow leak"}\n`);
|
|
expect(rows()).toHaveLength(2);
|
|
});
|
|
|
|
it("a CONTINUOUS storm stays one row past the window (each hit refreshes it)", () => {
|
|
vi.useFakeTimers();
|
|
let t = new Date("2026-07-08T10:00:00.000Z").getTime();
|
|
for (let i = 0; i < 10; i++) {
|
|
vi.setSystemTime(new Date(t));
|
|
stream.write(`{"level":"error","msg":"storm"}\n`);
|
|
t += 240_000; // 4 min apart — each inside the window of the PREVIOUS hit
|
|
}
|
|
const all = rows();
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0].context).toMatchObject({ _repeat: 10 });
|
|
});
|
|
});
|