@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/dist/panel.js ADDED
@@ -0,0 +1,102 @@
1
+ import { createServer } from 'node:http';
2
+ import { page } from './page.js';
3
+ /** How often the page is told how many calls are in flight. */
4
+ const BEAT_MS = 1000;
5
+ /**
6
+ * The panel's own door: the page, and the events that fill it.
7
+ *
8
+ * Its own server and not the app's, for one measured reason: `serve()` from
9
+ * `@fougere/transport-http` answers 404 to everything that is not `POST /_fougere/call`,
10
+ * which is what makes it safe to expose — a page cannot enter it without changing what it
11
+ * is. So the panel binds 127.0.0.1 only, and serves both halves itself, which also means
12
+ * the page reads its own origin and no CORS has to be arranged.
13
+ */
14
+ export function servePanel(ring, options = {}) {
15
+ const title = options.title ?? 'fougere';
16
+ const readers = new Set();
17
+ const html = page(title);
18
+ const send = (event, data) => {
19
+ const framed = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
20
+ for (const reader of readers) {
21
+ try {
22
+ reader.write(framed);
23
+ }
24
+ catch {
25
+ readers.delete(reader);
26
+ }
27
+ }
28
+ };
29
+ const stopWatching = ring.watch((record) => send('call', record));
30
+ const beat = setInterval(() => {
31
+ if (readers.size === 0)
32
+ return;
33
+ send('vitals', { inFlight: options.inFlight?.() ?? 0, dropped: ring.since(Number.MAX_SAFE_INTEGER).dropped });
34
+ }, BEAT_MS);
35
+ beat.unref();
36
+ const server = createServer((request, response) => {
37
+ const path = (request.url ?? '/').split('?')[0];
38
+ if (path === '/events') {
39
+ response.writeHead(200, {
40
+ 'content-type': 'text/event-stream',
41
+ 'cache-control': 'no-cache',
42
+ connection: 'keep-alive',
43
+ });
44
+ // The page is handed the ring as it stands, so a reader that opens late is not
45
+ // looking at an empty screen that suggests a quiet app.
46
+ const held = ring.since(0);
47
+ response.write(`event: hello\ndata: ${JSON.stringify({ calls: held.calls, fronds: options.fronds ?? [] })}\n\n`);
48
+ readers.add(response);
49
+ request.on('close', () => readers.delete(response));
50
+ return;
51
+ }
52
+ // One door per ring, each taking a cursor: a reader asks for what is above its own,
53
+ // which is the whole protocol — no subscription to hold, nothing to replay.
54
+ for (const [name, read] of [['logs', options.logs], ['errors', options.errors], ['queries', options.queries]]) {
55
+ if (path !== `/${name}.json`)
56
+ continue;
57
+ const since = Number(new URL(request.url ?? '/', 'http://panel').searchParams.get('since') ?? 0);
58
+ response.writeHead(200, { 'content-type': 'application/json' });
59
+ response.end(JSON.stringify(read?.(Number.isFinite(since) ? since : 0) ?? { lines: [], cursor: 0, dropped: 0 }));
60
+ return;
61
+ }
62
+ if (path === '/model.json') {
63
+ response.writeHead(200, { 'content-type': 'application/json' });
64
+ response.end(JSON.stringify(options.model ?? { fronds: [] }));
65
+ return;
66
+ }
67
+ if (path === '/calls.json') {
68
+ response.writeHead(200, { 'content-type': 'application/json' });
69
+ response.end(JSON.stringify(ring.since(0)));
70
+ return;
71
+ }
72
+ if (path === '/' || path === '/index.html') {
73
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
74
+ response.end(html);
75
+ return;
76
+ }
77
+ response.writeHead(404, { 'content-type': 'text/plain' });
78
+ response.end('the panel serves /, /events, /calls.json, /model.json, /logs.json, /errors.json and /queries.json\n');
79
+ });
80
+ return new Promise((ready, refuse) => {
81
+ server.on('error', refuse);
82
+ // Loopback only. This door has no judge — `serve()` refuses to start beyond loopback
83
+ // without `verify`, and this one would not know how to refuse at all.
84
+ server.listen(options.port ?? 0, '127.0.0.1', () => {
85
+ const port = server.address().port;
86
+ options.announce?.(`http://127.0.0.1:${port}`);
87
+ ready(async () => {
88
+ clearInterval(beat);
89
+ stopWatching();
90
+ for (const reader of readers) {
91
+ try {
92
+ reader.end();
93
+ }
94
+ catch { /* going away */ }
95
+ }
96
+ readers.clear();
97
+ await new Promise((closed) => server.close(() => closed()));
98
+ });
99
+ });
100
+ });
101
+ }
102
+ //# sourceMappingURL=panel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel.js","sourceRoot":"","sources":["../src/panel.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,88 @@
1
+ import { type DispatchEvent, type LogRecord } from '@fougere/core';
2
+ /** One line this process wrote. `args` is the developer's own choice of what to record. */
3
+ export interface LogLine {
4
+ seq: number;
5
+ level: LogRecord['level'];
6
+ name: string;
7
+ message: string;
8
+ /** Rendered here, not held: a live object would let the panel show what it later became. */
9
+ args: string[];
10
+ at: number;
11
+ }
12
+ /** One statement, as the panel shows it — never a parameter's value. */
13
+ export interface QueryLine {
14
+ seq: number;
15
+ storage: string;
16
+ sql: string;
17
+ parameters: number;
18
+ ms: number;
19
+ failed: boolean;
20
+ at: number;
21
+ }
22
+ /** One kind of refusal, and how often it happened. */
23
+ export interface ErrorGroup {
24
+ seq: number;
25
+ key: string;
26
+ code: string;
27
+ entity?: string;
28
+ operation?: string;
29
+ message: string;
30
+ count: number;
31
+ firstAt: number;
32
+ lastAt: number;
33
+ /** Which field was refused, for a VALIDATION_FAILED — the whole point of this source. */
34
+ fields: {
35
+ path: string;
36
+ message: string;
37
+ }[];
38
+ /** `dispatch` when a call carried it, `log` when it happened outside any call. */
39
+ from: 'dispatch' | 'log';
40
+ }
41
+ /** A bounded list that counts what it drops, so a busy moment never reads as a quiet one. */
42
+ declare class Ring<T extends {
43
+ seq: number;
44
+ }> {
45
+ private readonly max;
46
+ protected readonly held: T[];
47
+ protected seq: number;
48
+ private lost;
49
+ constructor(max?: number);
50
+ protected keep(make: (seq: number) => T): T;
51
+ since(cursor: number): {
52
+ lines: T[];
53
+ cursor: number;
54
+ dropped: number;
55
+ };
56
+ }
57
+ /**
58
+ * What this process logged.
59
+ *
60
+ * Chronological and nothing more: correlating a line to its call needs an async context,
61
+ * which core's `Ambient` port does not provide (it answers about frames and emission
62
+ * chains) and only `@fougere/observability` has. Aligning by timestamp would be a guess
63
+ * dressed as a fact — the failure mode of every panel that lies.
64
+ */
65
+ export declare class LogRing extends Ring<LogLine> {
66
+ record(line: LogRecord): void;
67
+ }
68
+ export declare class QueryRing extends Ring<QueryLine> {
69
+ record(event: Omit<QueryLine, 'seq'>): void;
70
+ }
71
+ /**
72
+ * What was refused, from TWO sources — and that is the point.
73
+ *
74
+ * Fed by the call flow alone, this screen would miss exactly the failures that matter: a
75
+ * storage that would not open, an export that could not be sent, an extension that fell
76
+ * over. None of those is a dispatch. It is the blind spot Symfony's Headers panel and
77
+ * Django's History panel both have, and the only cure is a second source.
78
+ */
79
+ export declare class ErrorRing extends Ring<ErrorGroup> {
80
+ private readonly byKey;
81
+ /** A refusal that a call carried. `details` reaches here intact, unlike a span's code. */
82
+ fromDispatch(event: DispatchEvent): void;
83
+ /** A line written at `error` level, which is how a failure outside any call speaks. */
84
+ fromLog(line: LogRecord): void;
85
+ private group;
86
+ }
87
+ export {};
88
+ //# sourceMappingURL=rings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rings.d.ts","sourceRoot":"","sources":["../src/rings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,eAAe,CAAC;AAKvF,2FAA2F;AAC3F,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,CAAC,OAAO,CAAC,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wEAAwE;AACxE,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,OAAO,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,sDAAsD;AACtD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,yFAAyF;IACzF,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5C,kFAAkF;IAClF,IAAI,EAAE,UAAU,GAAG,KAAK,CAAC;CAC1B;AAED,6FAA6F;AAC7F,cAAM,IAAI,CAAC,CAAC,SAAS;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE;IAKtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAJhC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,CAAM;IAClC,SAAS,CAAC,GAAG,SAAK;IAClB,OAAO,CAAC,IAAI,CAAK;IAEjB,YAA6B,GAAG,SAAO,EAAI;IAE3C,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,CAAC,GAAG,CAAC,CAM1C;IAED,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,CAAC,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAErE;CACF;AAED;;;;;;;GAOG;AACH,qBAAa,OAAQ,SAAQ,IAAI,CAAC,OAAO,CAAC;IACxC,MAAM,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAS5B;CACF;AAED,qBAAa,SAAU,SAAQ,IAAI,CAAC,SAAS,CAAC;IAC5C,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,GAAG,IAAI,CAE1C;CACF;AAED;;;;;;;GAOG;AACH,qBAAa,SAAU,SAAQ,IAAI,CAAC,UAAU,CAAC;IAC7C,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiC;IAEvD,0FAA0F;IAC1F,YAAY,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAYvC;IAED,uFAAuF;IACvF,OAAO,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAI7B;IAED,OAAO,CAAC,KAAK;CAqBd"}
package/dist/rings.js ADDED
@@ -0,0 +1,112 @@
1
+ import { validationErrorsOf } from '@fougere/core';
2
+ /** How many each ring keeps. Bounded by a NUMBER, never by a duration. */
3
+ const KEPT = 300;
4
+ /** A bounded list that counts what it drops, so a busy moment never reads as a quiet one. */
5
+ class Ring {
6
+ max;
7
+ held = [];
8
+ seq = 0;
9
+ lost = 0;
10
+ constructor(max = KEPT) {
11
+ this.max = max;
12
+ }
13
+ keep(make) {
14
+ const one = make(++this.seq);
15
+ this.held.push(one);
16
+ if (this.held.length > this.max)
17
+ this.lost += this.held.splice(0, this.held.length - this.max).length;
18
+ return one;
19
+ }
20
+ since(cursor) {
21
+ return { lines: this.held.filter((one) => one.seq > cursor), cursor: this.seq, dropped: this.lost };
22
+ }
23
+ }
24
+ /**
25
+ * What this process logged.
26
+ *
27
+ * Chronological and nothing more: correlating a line to its call needs an async context,
28
+ * which core's `Ambient` port does not provide (it answers about frames and emission
29
+ * chains) and only `@fougere/observability` has. Aligning by timestamp would be a guess
30
+ * dressed as a fact — the failure mode of every panel that lies.
31
+ */
32
+ export class LogRing extends Ring {
33
+ record(line) {
34
+ this.keep((seq) => ({
35
+ seq,
36
+ level: line.level,
37
+ name: line.name,
38
+ message: line.message,
39
+ args: line.args.map(render),
40
+ at: line.at,
41
+ }));
42
+ }
43
+ }
44
+ export class QueryRing extends Ring {
45
+ record(event) {
46
+ this.keep((seq) => ({ ...event, seq }));
47
+ }
48
+ }
49
+ /**
50
+ * What was refused, from TWO sources — and that is the point.
51
+ *
52
+ * Fed by the call flow alone, this screen would miss exactly the failures that matter: a
53
+ * storage that would not open, an export that could not be sent, an extension that fell
54
+ * over. None of those is a dispatch. It is the blind spot Symfony's Headers panel and
55
+ * Django's History panel both have, and the only cure is a second source.
56
+ */
57
+ export class ErrorRing extends Ring {
58
+ byKey = new Map();
59
+ /** A refusal that a call carried. `details` reaches here intact, unlike a span's code. */
60
+ fromDispatch(event) {
61
+ const error = event.error;
62
+ const { entity, operation } = event.call.address;
63
+ this.group({
64
+ code: typeof error?.code === 'string' ? error.code : 'INTERNAL_ERROR',
65
+ entity,
66
+ operation,
67
+ message: error?.message ?? String(event.error),
68
+ fields: (validationErrorsOf(error) ?? []).map((one) => ({ path: one.path, message: one.message })),
69
+ from: 'dispatch',
70
+ });
71
+ }
72
+ /** A line written at `error` level, which is how a failure outside any call speaks. */
73
+ fromLog(line) {
74
+ if (line.level !== 'error')
75
+ return;
76
+ this.group({ code: line.name, message: line.message, fields: [], from: 'log' });
77
+ }
78
+ group(one) {
79
+ const key = [one.from, one.code, one.entity ?? '', one.operation ?? ''].join(' ');
80
+ const held = this.byKey.get(key);
81
+ const at = Date.now();
82
+ // Grouped, because a refusal seen forty times is one line with a count — not forty
83
+ // lines that push everything else out of a bounded ring.
84
+ if (held) {
85
+ held.count += 1;
86
+ held.lastAt = at;
87
+ held.message = one.message;
88
+ if (one.fields.length > 0)
89
+ held.fields = one.fields;
90
+ // Re-numbered so a reader that already saw this group is handed the higher count.
91
+ // Without it a refusal seen forty times reports one, forever: the group is mutated
92
+ // in place, and a cursor asks only for what is above it.
93
+ held.seq = ++this.seq;
94
+ return;
95
+ }
96
+ this.byKey.set(key, this.keep((seq) => ({ ...one, key, seq, count: 1, firstAt: at, lastAt: at })));
97
+ }
98
+ }
99
+ /** An argument as one line. A live object would change under the reader; a string cannot. */
100
+ function render(value) {
101
+ if (typeof value === 'string')
102
+ return value;
103
+ if (value instanceof Error)
104
+ return `${value.name}: ${value.message}`;
105
+ try {
106
+ return JSON.stringify(value) ?? String(value);
107
+ }
108
+ catch {
109
+ return String(value);
110
+ }
111
+ }
112
+ //# sourceMappingURL=rings.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rings.js","sourceRoot":"","sources":["../src/rings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAsC,MAAM,eAAe,CAAC;AAEvF,0EAA0E;AAC1E,MAAM,IAAI,GAAG,GAAG,CAAC;AAyCjB,6FAA6F;AAC7F,MAAM,IAAI;IAKqB,GAAG;IAJb,IAAI,GAAQ,EAAE,CAAC;IACxB,GAAG,GAAG,CAAC,CAAC;IACV,IAAI,GAAG,CAAC,CAAC;IAEjB,YAA6B,GAAG,GAAG,IAAI;mBAAV,GAAG;IAAU,CAAC;IAEjC,IAAI,CAAC,IAAwB;QACrC,MAAM,GAAG,GAAG,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACpB,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG;YAAE,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;QAEtG,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,MAAc;QAClB,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,GAAG,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;IACtG,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,OAAQ,SAAQ,IAAa;IACxC,MAAM,CAAC,IAAe;QACpB,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YAClB,GAAG;YACH,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC;YAC3B,EAAE,EAAE,IAAI,CAAC,EAAE;SACZ,CAAC,CAAC,CAAC;IACN,CAAC;CACF;AAED,MAAM,OAAO,SAAU,SAAQ,IAAe;IAC5C,MAAM,CAAC,KAA6B;QAClC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;IAC1C,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,SAAU,SAAQ,IAAgB;IAC5B,KAAK,GAAG,IAAI,GAAG,EAAsB,CAAC;IAEvD,0FAA0F;IAC1F,YAAY,CAAC,KAAoB;QAC/B,MAAM,KAAK,GAAG,KAAK,CAAC,KAA2E,CAAC;QAChG,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC;QAEjD,IAAI,CAAC,KAAK,CAAC;YACT,IAAI,EAAE,OAAO,KAAK,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,gBAAgB;YACrE,MAAM;YACN,SAAS;YACT,OAAO,EAAE,KAAK,EAAE,OAAO,IAAI,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC;YAC9C,MAAM,EAAE,CAAC,kBAAkB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC;YAClG,IAAI,EAAE,UAAU;SACjB,CAAC,CAAC;IACL,CAAC;IAED,uFAAuF;IACvF,OAAO,CAAC,IAAe;QACrB,IAAI,IAAI,CAAC,KAAK,KAAK,OAAO;YAAE,OAAO;QAEnC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAClF,CAAC;IAEO,KAAK,CAAC,GAAqE;QACjF,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,MAAM,IAAI,EAAE,EAAE,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClF,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACjC,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEtB,mFAAmF;QACnF,yDAAyD;QACzD,IAAI,IAAI,EAAE,CAAC;YACT,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;YAChB,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;YACjB,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;YAC3B,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC;gBAAE,IAAI,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC;YACpD,kFAAkF;YAClF,mFAAmF;YACnF,yDAAyD;YACzD,IAAI,CAAC,GAAG,GAAG,EAAE,IAAI,CAAC,GAAG,CAAC;YACtB,OAAO;QACT,CAAC;QAED,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IACrG,CAAC;CACF;AAED,6FAA6F;AAC7F,SAAS,MAAM,CAAC,KAAc;IAC5B,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,IAAI,KAAK,YAAY,KAAK;QAAE,OAAO,GAAG,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;IACrE,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IACvB,CAAC;AACH,CAAC"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@fougere/calls",
3
+ "version": "0.5.0-alpha.0",
4
+ "description": "Optional call log for Fougere: a bounded ring of every dispatch, served as an rpc operation. Read it with `fougere devtools`.",
5
+ "keywords": [
6
+ "fougere",
7
+ "typescript",
8
+ "devtools",
9
+ "observability",
10
+ "dispatch"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/chok/fougere.git",
16
+ "directory": "packages/calls"
17
+ },
18
+ "type": "module",
19
+ "main": "dist/index.js",
20
+ "types": "dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "peerDependencies": {
33
+ "@fougere/core": "^0.5.0-alpha.0",
34
+ "@fougere/adapter-sql": "^0.5.0-alpha.0"
35
+ },
36
+ "devDependencies": {
37
+ "vitest": "^4.1.0",
38
+ "@fougere/adapter-sql": "0.5.0-alpha.0",
39
+ "@fougere/container": "0.5.0-alpha.0",
40
+ "@fougere/core": "0.5.0-alpha.0",
41
+ "@fougere/schema": "0.5.0-alpha.0",
42
+ "@fougere/transport-http": "0.5.0-alpha.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@fougere/adapter-sql": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "scripts": {
53
+ "build": "rm -rf dist && tsc",
54
+ "test": "vitest run",
55
+ "test:watch": "vitest",
56
+ "typecheck": "tsc --noEmit -p tsconfig.test.json"
57
+ }
58
+ }
@@ -0,0 +1,119 @@
1
+ import { RPC_ENTITY, type CallPage, type CallRecord, type DispatchEvent } from '@fougere/core';
2
+
3
+ /** The message and the code of a refusal, without the stack that carries a body. */
4
+ function refusalOf(error: unknown): CallRecord['refusal'] {
5
+ if (error === null || error === undefined) return { message: 'unknown refusal' };
6
+ const held = error as { code?: unknown; message?: unknown };
7
+
8
+ return {
9
+ ...(typeof held.code === 'string' ? { code: held.code } : {}),
10
+ message: typeof held.message === 'string' ? held.message : String(error),
11
+ };
12
+ }
13
+
14
+ /**
15
+ * A bounded log of what this process dispatched.
16
+ *
17
+ * The five transitions of one call are folded into one record: `received` opens it,
18
+ * `resolved` names the route, `completed`/`failed` settle the verdict, `settled` closes
19
+ * the duration. They are matched by the identity of the `Call`, which is the same frozen
20
+ * object across all five.
21
+ */
22
+ export class CallRing {
23
+ private readonly held: CallRecord[] = [];
24
+ private readonly open = new WeakMap<object, CallRecord>();
25
+ private seq = 0;
26
+ private lost = 0;
27
+ private readonly watching = new Set<(record: CallRecord) => void>();
28
+
29
+ constructor(
30
+ private readonly max = 500,
31
+ private readonly frondOf: (entity: string) => string | undefined = () => undefined,
32
+ ) {}
33
+
34
+ /**
35
+ * KNOWN LIMIT — a call this process ORIGINATES carries no trace here.
36
+ *
37
+ * `trace()` writes the traceparent inside the middleware chain, so inside
38
+ * `route.execute(call)`; the observer saw the `Call` at `received`, and that object is
39
+ * frozen. An ARRIVING call is unaffected: its traceparent is already on the invocation
40
+ * the transport handed over, which is why a hosted frond shows traces and its consumer
41
+ * does not.
42
+ *
43
+ * Closing it means `DispatchEvent` carrying the invocation as it ENDED rather than as it
44
+ * arrived — a core change, deliberately not smuggled in here. Until then the Traces view
45
+ * speaks for the side that receives.
46
+ */
47
+ record(event: DispatchEvent): void {
48
+ // A reader reaches this ring through `rpc`, so recording that would make the panel
49
+ // watch itself — one reader, one line, forever.
50
+ if (event.call.address.entity === RPC_ENTITY) return;
51
+
52
+ if (event.stage === 'received') return this.opened(event);
53
+
54
+ const record = this.open.get(event.call);
55
+ if (!record) return;
56
+
57
+ if (event.stage === 'resolved' && event.routeKind) record.route = event.routeKind;
58
+ if (event.stage === 'completed') record.verdict = 'ok';
59
+ if (event.stage === 'failed') {
60
+ record.verdict = 'failed';
61
+ record.refusal = refusalOf(event.error);
62
+ }
63
+ if (event.stage === 'settled') {
64
+ record.ms = Date.now() - record.startedAt;
65
+ if (event.routeKind) record.route = event.routeKind;
66
+ this.open.delete(event.call);
67
+ for (const told of this.watching) {
68
+ // A watcher's own failure is not the dispatch's problem — the same rule
69
+ // `DispatchLifecycle` applies to an observer.
70
+ try { told(record); } catch { /* observational */ }
71
+ }
72
+ }
73
+ }
74
+
75
+ private opened(event: DispatchEvent): void {
76
+ const { entity, operation, surface } = event.call.address;
77
+ const frond = this.frondOf(entity);
78
+ const { trace, caller } = event.call.invocation;
79
+ const record: CallRecord = {
80
+ seq: ++this.seq,
81
+ ...(frond ? { frond } : {}),
82
+ entity,
83
+ operation,
84
+ ...(surface !== undefined ? { surface } : {}),
85
+ ...(trace ? { trace } : {}),
86
+ ...(caller ? { caller } : {}),
87
+ startedAt: Date.now(),
88
+ verdict: 'running',
89
+ };
90
+
91
+ this.held.push(record);
92
+ this.open.set(event.call, record);
93
+
94
+ if (this.held.length > this.max) this.lost += this.held.splice(0, this.held.length - this.max).length;
95
+ }
96
+
97
+ /**
98
+ * Be told when a record settles, and get the unsubscription back.
99
+ *
100
+ * A page that polls is a page that is always a little wrong; this is what lets a door
101
+ * push instead. Told at `settled` and not at `received`, because a record is only
102
+ * complete then — a reader would otherwise redraw the same line four times.
103
+ */
104
+ watch(told: (record: CallRecord) => void): () => void {
105
+ this.watching.add(told);
106
+
107
+ return () => this.watching.delete(told);
108
+ }
109
+
110
+ /** Everything above `cursor`, and what was lost while the reader was away. */
111
+ since(cursor: number, inFlight = 0): CallPage {
112
+ return {
113
+ calls: this.held.filter((record) => record.seq > cursor),
114
+ cursor: this.seq,
115
+ inFlight,
116
+ dropped: this.lost,
117
+ };
118
+ }
119
+ }