mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
1 parent
0e957965a6
commit
b5c9f5759b
4 files changed
+74
-23
No files matched your search
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user