fix(ui): count only identical log entries and keep a count beside its entry

The ui-log buffer compared level, source, project and message only, so the
same uncaught TypeError from a second place, or a request failure with
another response body, was counted instead of sent: its location never
reached the log. The buffer now compares every field it sends, and a repeat
count carries the fields of the entry it counts, the detail on its second
line.

System › Logs grouped an entry with a detail apart from its own counts, so
one failure read as two rows. It now groups by the same fields as the
buffer.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-03 20:00:23 +02:00
1 parent 0e957965a6
commit b5c9f5759b
4 files changed
+74 -23

No files matched your search

+20
View File
@@ -190,6 +190,26 @@ describe('UiLogBuffer', () => {
expect(buffer.stats().sent).toBe(5);
});
it('counts only truly identical entries: the same message from another place or with another detail is sent in full', async () => {
const posts: UiLogPayload[] = [];
const buffer = new UiLogBuffer({ page: '/', session: 'places', schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200,
transport: { send: async (payload) => { posts.push(payload); return true; } } });
const typeError = "Uncaught TypeError: Cannot read properties of undefined (reading 'x')";
buffer.record('error', 'window', typeError, { project: 'cbm', url: 'a.js', line: 10, col: 3, stack: 'at A (a.js:10)' });
buffer.record('error', 'window', typeError, { project: 'cbm', url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'body one' });
buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'body two' });
for (let i = 0; i < 11; i++) buffer.record('error', 'window', typeError, { project: 'cbm', url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
await buffer.flush(true);
const sent = posts.flatMap((post) => post.entries);
// Both places and both response bodies reach the log in full; the twelve from b.js:99 become one entry and its count.
expect(sent.map((entry) => [entry.source, entry.url ?? entry.detail?.split('\n').at(-1), entry.line, repeatCount(entry.detail)])).toEqual([
['window', 'a.js', 10, undefined], ['window', 'b.js', 99, undefined], ['rpc', 'body one', undefined, undefined],
['rpc', 'body two', undefined, undefined], ['window', 'b.js', 99, 10], ['window', 'b.js', 99, 12]]);
// A count names the place it counts, so System › Logs can put it beside its first entry.
expect(sent.at(-1)).toMatchObject({ url: 'b.js', line: 99, col: 7, stack: 'at B (b.js:99)' });
});
it('does not throw when the transport does', async () => {
const throwing: UiLogTransport = {
send: async () => {
+26 -16
View File
@@ -31,12 +31,14 @@
* characters, the same cap the server applies; a cut is marked so nobody
* reads a truncated stack as a complete one.
* - **Once per session, then counted.** An entry identical to an earlier
* one of this session (level, source, project and message) is counted,
* not queued. A deprecation warning that a library prints on every scene
* mount (THREE.Clock from @react-three/fiber) would otherwise push the
* real messages out of System › Logs. The count goes out as one more
* entry with the same text at 10, 100, 1000 repeats and when the page is
* left (`final`), so the journal holds a handful of lines, not hundreds.
* one of this session (level, source, project, message, detail, stack and
* place) is counted, not queued. A deprecation warning that a library
* prints on every scene mount (THREE.Clock from @react-three/fiber) would
* otherwise push the real messages out of System › Logs. The count goes
* out as one more entry with the same fields at 10, 100, 1000 repeats and
* when the page is left (`final`), so the journal holds a handful of
* lines, not hundreds. The same message from another place or with
* another response body is another entry and is recorded in full.
*
* Time and timers are injectable so a test runs in one tick.
*/
@@ -120,10 +122,12 @@ export interface UiLogOptions {
/**
* The detail of a repeat count. The server keeps only the known fields of an
* entry, so the count travels in `detail`, in words a reader understands and
* in a form System › Logs reads back (repeatCount).
* in a form System › Logs reads back (repeatCount). The detail of the counted
* entry follows on the next line, so the count stays beside its own entry.
*/
export function repeatDetail(count: number): string {
return `${count} identical entries in this session; only the first is recorded in full`;
export function repeatDetail(count: number, detail?: string): string {
const words = `${count} identical entries in this session; only the first is recorded in full`;
return detail ? capField(`${words}\n${detail}`) : words;
}
/** The count of a repeat entry, undefined for an ordinary one. */
@@ -132,6 +136,13 @@ export function repeatCount(detail: string | undefined): number | undefined {
return match ? Number(match[1]) : undefined;
}
/** The detail of the entry a repeat count stands for; an ordinary detail as it is. */
export function countedDetail(detail: string | undefined): string | undefined {
if (repeatCount(detail) === undefined) return detail;
const newline = detail!.indexOf('\n');
return newline < 0 ? undefined : detail!.slice(newline + 1);
}
/** Cut a text field at the shared cap and say so. */
export function capField(text: string, max = UI_LOG_FIELD_MAX): string {
return text.length > max ? `${text.slice(0, max)} [cut at ${max}]` : text;
@@ -211,7 +222,9 @@ export class UiLogBuffer {
if (typeof extra.col === 'number' && Number.isFinite(extra.col)) {
entry.col = extra.col;
}
const key = [entry.level, entry.source, entry.project ?? '', entry.message].join('\u0000');
// Only a truly identical entry is counted: the same text from another place stays its own entry.
const key = JSON.stringify([entry.level, entry.source, entry.project ?? '', entry.message, entry.detail ?? '',
entry.stack ?? '', entry.url ?? '', entry.line ?? null, entry.col ?? null]);
const repeat = this.repeats.get(key);
if (repeat !== undefined) {
this.seq -= 1;
@@ -229,13 +242,10 @@ export class UiLogBuffer {
this.arm(this.flushMs);
}
/** Queue the running count of a repeated entry, with the text of its first occurrence. */
/** Queue the running count of a repeated entry, with every field of its first occurrence. */
private report(repeat: Repeat): void {
const { level, source, message, project } = repeat.entry;
const entry: UiLogEntry = { ts: this.now().toISOString(), seq: ++this.seq, level, source, message, detail: repeatDetail(repeat.count) };
if (project !== undefined) {
entry.project = project;
}
const { ts: _ts, seq: _seq, detail, ...first } = repeat.entry;
const entry: UiLogEntry = { ...first, ts: this.now().toISOString(), seq: ++this.seq, detail: repeatDetail(repeat.count, detail) };
repeat.reported = repeat.count;
this.push(entry);
}
+16 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest';
import { readProcesses } from '../projects/projects-model';
import { collapseRepeats, cpuText, filterLogs, logLevel, memoryLabel, memoryText } from './system-model';
import { repeatDetail } from '../app/ui-log';
import { repeatDetail, UiLogBuffer } from '../app/ui-log';
import type { UiLogPayload } from '../app/ui-log';
describe('System measurements', () => {
it('keeps missing readings distinct from measured zero', () => {
@@ -66,4 +67,18 @@ describe('System log levels', () => {
expect(rows.map((row) => [row.record.id, row.count])).toEqual([[3, 1], [6, 1], [9, 123]]);
expect(rows[2]?.first).toBe('2026-10-03T16:05:01Z');
});
it('keeps a repeated request failure with a response body on one row with its count, and its body', async () => {
// What the buffer actually posts for twelve identical failures, stored the way the daemon stores them.
const posted: UiLogPayload[] = [];
const buffer = new UiLogBuffer({ page: '/?project=cbm', session: 'rpc12', schedule: () => 0, cancel: () => undefined, bufferMax: 200, batchMax: 200,
transport: { send: async (payload) => { posted.push(payload); return true; } } });
for (let i = 0; i < 12; i++) buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'HTTP 500 body text' });
buffer.record('error', 'rpc', '/rpc query_graph: HTTP 500', { project: 'cbm', detail: 'another body' });
await buffer.flush(true);
const records = posted.flatMap((post) => post.entries.map((entry) => ({ ...entry, session: post.session, page: post.page })))
.map((line, index) => ({ id: index + 1, ts: line.ts, level: line.level, source: line.source, project: line.project, message: JSON.stringify(line) }));
const rows = collapseRepeats(records);
expect(rows.map((row) => [JSON.parse(row.record.message).detail.split('\n').at(-1), row.count])).toEqual([['another body', 1], ['HTTP 500 body text', 12]]);
});
});
+12 -6
View File
@@ -1,5 +1,5 @@
import type { DaemonLogRecord, ProcessCpuUnit, ProcessMemoryKind } from '../projects/projects-model';
import { repeatCount } from '../app/ui-log';
import { countedDetail, repeatCount } from '../app/ui-log';
export function cpuText(value: number | null | undefined, unit: ProcessCpuUnit | undefined): string {
if (value == null || !Number.isFinite(value) || value < 0) return 'Unavailable';
@@ -47,20 +47,26 @@ export function filterLogs(lines: readonly string[], query: string, level: LogLe
export interface LogRow { record: DaemonLogRecord; count: number; first: string }
/** The text that makes two records the same message: a frontend line names its session and its own message. */
/**
* The fields that make two records the same message, the ones the frontend
* buffer compares (app/ui-log.ts): a repeat count carries them too, so it joins
* the row of the entry it counts. The detail is compared by its start, which a
* field cut on the longer count line leaves intact.
*/
function repeatIdentity(record: DaemonLogRecord): { key: string; session: string; repeat?: number } {
try {
const line: unknown = JSON.parse(record.message);
if (typeof line === 'object' && line !== null && typeof (line as Record<string, unknown>)['message'] === 'string') {
const entry = line as Record<string, unknown>;
const text = (name: string) => typeof entry[name] === 'string' ? entry[name] as string : '';
const detail = typeof entry['detail'] === 'string' ? entry['detail'] : undefined;
const repeat = repeatCount(detail);
const own = repeat === undefined && detail ? `\u0000${detail}` : '';
return { key: `${record.level}\u0000${record.source}\u0000${record.project ?? ''}\u0000${String(entry['message'])}${own}`,
session: typeof entry['session'] === 'string' ? entry['session'] : '', repeat };
return { key: JSON.stringify([record.level, record.source, record.project ?? '', text('message'), (countedDetail(detail) ?? '').slice(0, 2048),
text('stack').slice(0, 2048), text('url'), entry['line'] ?? null, entry['col'] ?? null]),
session: text('session'), repeat };
}
} catch { /* A daemon line is plain text. */ }
return { key: `${record.level}\u0000${record.source}\u0000${record.project ?? ''}\u0000${record.message}`, session: '' };
return { key: JSON.stringify([record.level, record.source, record.project ?? '', record.message]), session: '' };
}
/**