@fougere/calls 0.5.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rings.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { validationErrorsOf, type DispatchEvent, type LogRecord } from '@fougere/core';
2
+
3
+ /** How many each ring keeps. Bounded by a NUMBER, never by a duration. */
4
+ const KEPT = 300;
5
+
6
+ /** One line this process wrote. `args` is the developer's own choice of what to record. */
7
+ export interface LogLine {
8
+ seq: number;
9
+ level: LogRecord['level'];
10
+ name: string;
11
+ message: string;
12
+ /** Rendered here, not held: a live object would let the panel show what it later became. */
13
+ args: string[];
14
+ at: number;
15
+ }
16
+
17
+ /** One statement, as the panel shows it — never a parameter's value. */
18
+ export interface QueryLine {
19
+ seq: number;
20
+ storage: string;
21
+ sql: string;
22
+ parameters: number;
23
+ ms: number;
24
+ failed: boolean;
25
+ at: number;
26
+ }
27
+
28
+ /** One kind of refusal, and how often it happened. */
29
+ export interface ErrorGroup {
30
+ seq: number;
31
+ key: string;
32
+ code: string;
33
+ entity?: string;
34
+ operation?: string;
35
+ message: string;
36
+ count: number;
37
+ firstAt: number;
38
+ lastAt: number;
39
+ /** Which field was refused, for a VALIDATION_FAILED — the whole point of this source. */
40
+ fields: { path: string; message: string }[];
41
+ /** `dispatch` when a call carried it, `log` when it happened outside any call. */
42
+ from: 'dispatch' | 'log';
43
+ }
44
+
45
+ /** A bounded list that counts what it drops, so a busy moment never reads as a quiet one. */
46
+ class Ring<T extends { seq: number }> {
47
+ protected readonly held: T[] = [];
48
+ protected seq = 0;
49
+ private lost = 0;
50
+
51
+ constructor(private readonly max = KEPT) {}
52
+
53
+ protected keep(make: (seq: number) => T): T {
54
+ const one = make(++this.seq);
55
+ this.held.push(one);
56
+ if (this.held.length > this.max) this.lost += this.held.splice(0, this.held.length - this.max).length;
57
+
58
+ return one;
59
+ }
60
+
61
+ since(cursor: number): { lines: T[]; cursor: number; dropped: number } {
62
+ return { lines: this.held.filter((one) => one.seq > cursor), cursor: this.seq, dropped: this.lost };
63
+ }
64
+ }
65
+
66
+ /**
67
+ * What this process logged.
68
+ *
69
+ * Chronological and nothing more: correlating a line to its call needs an async context,
70
+ * which core's `Ambient` port does not provide (it answers about frames and emission
71
+ * chains) and only `@fougere/observability` has. Aligning by timestamp would be a guess
72
+ * dressed as a fact — the failure mode of every panel that lies.
73
+ */
74
+ export class LogRing extends Ring<LogLine> {
75
+ record(line: LogRecord): void {
76
+ this.keep((seq) => ({
77
+ seq,
78
+ level: line.level,
79
+ name: line.name,
80
+ message: line.message,
81
+ args: line.args.map(render),
82
+ at: line.at,
83
+ }));
84
+ }
85
+ }
86
+
87
+ export class QueryRing extends Ring<QueryLine> {
88
+ record(event: Omit<QueryLine, 'seq'>): void {
89
+ this.keep((seq) => ({ ...event, seq }));
90
+ }
91
+ }
92
+
93
+ /**
94
+ * What was refused, from TWO sources — and that is the point.
95
+ *
96
+ * Fed by the call flow alone, this screen would miss exactly the failures that matter: a
97
+ * storage that would not open, an export that could not be sent, an extension that fell
98
+ * over. None of those is a dispatch. It is the blind spot Symfony's Headers panel and
99
+ * Django's History panel both have, and the only cure is a second source.
100
+ */
101
+ export class ErrorRing extends Ring<ErrorGroup> {
102
+ private readonly byKey = new Map<string, ErrorGroup>();
103
+
104
+ /** A refusal that a call carried. `details` reaches here intact, unlike a span's code. */
105
+ fromDispatch(event: DispatchEvent): void {
106
+ const error = event.error as { code?: string; message?: string; details?: unknown } | undefined;
107
+ const { entity, operation } = event.call.address;
108
+
109
+ this.group({
110
+ code: typeof error?.code === 'string' ? error.code : 'INTERNAL_ERROR',
111
+ entity,
112
+ operation,
113
+ message: error?.message ?? String(event.error),
114
+ fields: (validationErrorsOf(error) ?? []).map((one) => ({ path: one.path, message: one.message })),
115
+ from: 'dispatch',
116
+ });
117
+ }
118
+
119
+ /** A line written at `error` level, which is how a failure outside any call speaks. */
120
+ fromLog(line: LogRecord): void {
121
+ if (line.level !== 'error') return;
122
+
123
+ this.group({ code: line.name, message: line.message, fields: [], from: 'log' });
124
+ }
125
+
126
+ private group(one: Omit<ErrorGroup, 'seq' | 'key' | 'count' | 'firstAt' | 'lastAt'>): void {
127
+ const key = [one.from, one.code, one.entity ?? '', one.operation ?? ''].join(' ');
128
+ const held = this.byKey.get(key);
129
+ const at = Date.now();
130
+
131
+ // Grouped, because a refusal seen forty times is one line with a count — not forty
132
+ // lines that push everything else out of a bounded ring.
133
+ if (held) {
134
+ held.count += 1;
135
+ held.lastAt = at;
136
+ held.message = one.message;
137
+ if (one.fields.length > 0) held.fields = one.fields;
138
+ // Re-numbered so a reader that already saw this group is handed the higher count.
139
+ // Without it a refusal seen forty times reports one, forever: the group is mutated
140
+ // in place, and a cursor asks only for what is above it.
141
+ held.seq = ++this.seq;
142
+ return;
143
+ }
144
+
145
+ this.byKey.set(key, this.keep((seq) => ({ ...one, key, seq, count: 1, firstAt: at, lastAt: at })));
146
+ }
147
+ }
148
+
149
+ /** An argument as one line. A live object would change under the reader; a string cannot. */
150
+ function render(value: unknown): string {
151
+ if (typeof value === 'string') return value;
152
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
153
+ try {
154
+ return JSON.stringify(value) ?? String(value);
155
+ } catch {
156
+ return String(value);
157
+ }
158
+ }