@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/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/CallRing.d.ts +45 -0
- package/dist/CallRing.d.ts.map +1 -0
- package/dist/CallRing.js +119 -0
- package/dist/CallRing.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +23 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +536 -0
- package/dist/page.js.map +1 -0
- package/dist/panel.d.ts +30 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +102 -0
- package/dist/panel.js.map +1 -0
- package/dist/rings.d.ts +88 -0
- package/dist/rings.d.ts.map +1 -0
- package/dist/rings.js +112 -0
- package/dist/rings.js.map +1 -0
- package/package.json +58 -0
- package/src/CallRing.ts +119 -0
- package/src/index.ts +205 -0
- package/src/page.ts +535 -0
- package/src/panel.ts +123 -0
- package/src/rings.ts +158 -0
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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"}
|
package/dist/rings.d.ts
ADDED
|
@@ -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":"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"}
|
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
|
+
}
|
package/src/CallRing.ts
ADDED
|
@@ -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
|
+
}
|