@atolljs/devtools 0.1.7 → 0.1.8
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/README.md +408 -71
- package/app/api.js +68 -0
- package/app/index.html +161 -2
- package/app/main.js +877 -127
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/{chunk-4N3NBFL3.js → chunk-AGT6VNZI.js} +179 -217
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +153 -27
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
package/src/otelMap.ts
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure devtools-event → OTLP/HTTP JSON mapping (no transport, no globals
|
|
3
|
+
* beyond crypto for ids). `otel.ts` drives it from a devtools sink; the
|
|
4
|
+
* aggregate server drives one mapper per app session.
|
|
5
|
+
*
|
|
6
|
+
* Encoding follows the OTLP JSON Protobuf mapping: lowerCamelCase keys,
|
|
7
|
+
* traceId/spanId as hex strings, enums as integers, 64-bit integers
|
|
8
|
+
* (timestamps, intValue, counts) as decimal strings.
|
|
9
|
+
*/
|
|
10
|
+
import type { EmittedDevtoolsEvent, TaskSettleOutcome } from '@atolljs/core';
|
|
11
|
+
|
|
12
|
+
/* ── OTLP JSON shapes ────────────────────────────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
export type AnyValue =
|
|
15
|
+
| { stringValue: string }
|
|
16
|
+
| { intValue: string }
|
|
17
|
+
| { doubleValue: number }
|
|
18
|
+
| { boolValue: boolean };
|
|
19
|
+
|
|
20
|
+
export interface KeyValue {
|
|
21
|
+
key: string;
|
|
22
|
+
value: AnyValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OtlpSpanEvent {
|
|
26
|
+
timeUnixNano: string;
|
|
27
|
+
name: string;
|
|
28
|
+
attributes?: KeyValue[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OtlpSpan {
|
|
32
|
+
traceId: string;
|
|
33
|
+
spanId: string;
|
|
34
|
+
parentSpanId?: string;
|
|
35
|
+
name: string;
|
|
36
|
+
kind: number;
|
|
37
|
+
startTimeUnixNano: string;
|
|
38
|
+
endTimeUnixNano: string;
|
|
39
|
+
attributes: KeyValue[];
|
|
40
|
+
events?: OtlpSpanEvent[];
|
|
41
|
+
status?: { code: number; message?: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OtlpLogRecord {
|
|
45
|
+
timeUnixNano: string;
|
|
46
|
+
observedTimeUnixNano: string;
|
|
47
|
+
severityNumber: number;
|
|
48
|
+
severityText: string;
|
|
49
|
+
body: AnyValue;
|
|
50
|
+
attributes: KeyValue[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OtlpNumberPoint {
|
|
54
|
+
attributes: KeyValue[];
|
|
55
|
+
startTimeUnixNano?: string;
|
|
56
|
+
timeUnixNano: string;
|
|
57
|
+
asInt?: string;
|
|
58
|
+
asDouble?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface OtlpHistogramPoint {
|
|
62
|
+
attributes: KeyValue[];
|
|
63
|
+
startTimeUnixNano: string;
|
|
64
|
+
timeUnixNano: string;
|
|
65
|
+
count: string;
|
|
66
|
+
sum: number;
|
|
67
|
+
min: number;
|
|
68
|
+
max: number;
|
|
69
|
+
bucketCounts: string[];
|
|
70
|
+
explicitBounds: number[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OtlpMetric {
|
|
74
|
+
name: string;
|
|
75
|
+
unit: string;
|
|
76
|
+
description: string;
|
|
77
|
+
sum?: { aggregationTemporality: number; isMonotonic: boolean; dataPoints: OtlpNumberPoint[] };
|
|
78
|
+
gauge?: { dataPoints: OtlpNumberPoint[] };
|
|
79
|
+
histogram?: { aggregationTemporality: number; dataPoints: OtlpHistogramPoint[] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Span.SpanKind */
|
|
83
|
+
export const SPAN_KIND_CLIENT = 3;
|
|
84
|
+
/** Status.StatusCode */
|
|
85
|
+
export const STATUS_ERROR = 2;
|
|
86
|
+
/** AggregationTemporality */
|
|
87
|
+
export const TEMPORALITY_CUMULATIVE = 2;
|
|
88
|
+
/** SeverityNumber base value per level. */
|
|
89
|
+
export const SEVERITY: Record<'trace' | 'debug' | 'info' | 'warn' | 'error', number> = {
|
|
90
|
+
trace: 1,
|
|
91
|
+
debug: 5,
|
|
92
|
+
info: 9,
|
|
93
|
+
warn: 13,
|
|
94
|
+
error: 17,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const OTEL_SCOPE = { name: '@atolljs/devtools' };
|
|
98
|
+
|
|
99
|
+
/* ── primitives ──────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
export const attr = {
|
|
102
|
+
str: (key: string, v: string): KeyValue => ({ key, value: { stringValue: v } }),
|
|
103
|
+
int: (key: string, v: number): KeyValue => ({ key, value: { intValue: String(Math.trunc(v)) } }),
|
|
104
|
+
dbl: (key: string, v: number): KeyValue => ({ key, value: { doubleValue: v } }),
|
|
105
|
+
bool: (key: string, v: boolean): KeyValue => ({ key, value: { boolValue: v } }),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Untyped attribute bag → KeyValues: integers as intValue, other numbers as doubleValue. */
|
|
109
|
+
export const toAttributes = (bag: Record<string, string | number | boolean | undefined>): KeyValue[] => {
|
|
110
|
+
const out: KeyValue[] = [];
|
|
111
|
+
for (const [k, v] of Object.entries(bag)) {
|
|
112
|
+
if (v === undefined) continue;
|
|
113
|
+
if (typeof v === 'string') out.push(attr.str(k, v));
|
|
114
|
+
else if (typeof v === 'boolean') out.push(attr.bool(k, v));
|
|
115
|
+
else if (Number.isSafeInteger(v)) out.push(attr.int(k, v));
|
|
116
|
+
else if (Number.isFinite(v)) out.push(attr.dbl(k, v));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Epoch milliseconds (fractional) → nanoseconds as a decimal string, without float overflow. */
|
|
122
|
+
export const toUnixNano = (epochMs: number): string => {
|
|
123
|
+
let whole = Math.floor(epochMs);
|
|
124
|
+
let frac = Math.round((epochMs - whole) * 1e6);
|
|
125
|
+
if (frac >= 1e6) {
|
|
126
|
+
whole += 1;
|
|
127
|
+
frac -= 1e6;
|
|
128
|
+
}
|
|
129
|
+
return (BigInt(whole) * 1_000_000n + BigInt(frac)).toString();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const hex = (bytes: number): string => {
|
|
133
|
+
const a = new Uint8Array(bytes);
|
|
134
|
+
const c = (globalThis as { crypto?: Crypto }).crypto;
|
|
135
|
+
if (c?.getRandomValues) c.getRandomValues(a);
|
|
136
|
+
else for (let i = 0; i < bytes; i++) a[i] = (Math.random() * 256) | 0;
|
|
137
|
+
// All-zero ids are invalid per the trace spec.
|
|
138
|
+
if (a.every((b) => b === 0)) a[bytes - 1] = 1;
|
|
139
|
+
let s = '';
|
|
140
|
+
for (const b of a) s += b.toString(16).padStart(2, '0');
|
|
141
|
+
return s;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** 16 random bytes → 32 hex chars. */
|
|
145
|
+
export const newTraceId = (): string => hex(16);
|
|
146
|
+
/** 8 random bytes → 16 hex chars. */
|
|
147
|
+
export const newSpanId = (): string => hex(8);
|
|
148
|
+
|
|
149
|
+
/* ── clock ───────────────────────────────────────────────────────────────── */
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Converts an event's `at` (performance.now() on the emitting thread) to
|
|
153
|
+
* epoch ms. Main-thread events with a known `originMs`
|
|
154
|
+
* (performance.timeOrigin of that thread) convert exactly. Everything else
|
|
155
|
+
* (worker-forwarded events carry the worker's own clock; remote sessions
|
|
156
|
+
* have an unknown origin) is rebased per clock: offset = min over samples
|
|
157
|
+
* of (receive time − at), i.e. the least-latency delivery seen so far.
|
|
158
|
+
* Durations and intra-thread order survive; absolute times are late by at
|
|
159
|
+
* most that minimum delivery latency. A jump of more than `resetMs` above
|
|
160
|
+
* the current estimate is treated as a new clock (a respawned worker in a
|
|
161
|
+
* reused slot) and re-seeds the offset.
|
|
162
|
+
*/
|
|
163
|
+
export class ClockRebaser {
|
|
164
|
+
private offsets = new Map<string, number>();
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
private readonly originMs?: number,
|
|
168
|
+
private readonly resetMs = 1000,
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
epochMs(ev: Pick<EmittedDevtoolsEvent, 'at' | 'thread' | 'worker'>, recvEpochMs: number): number {
|
|
172
|
+
if (!ev.worker && this.originMs !== undefined) return this.originMs + ev.at;
|
|
173
|
+
const key = ev.worker ? `${ev.worker.poolId}#${ev.worker.slot}` : ev.thread;
|
|
174
|
+
const d = recvEpochMs - ev.at;
|
|
175
|
+
const cur = this.offsets.get(key);
|
|
176
|
+
const off = cur === undefined || d < cur || d - cur > this.resetMs ? d : cur;
|
|
177
|
+
this.offsets.set(key, off);
|
|
178
|
+
return ev.at + off;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── metrics aggregation ─────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
interface Series {
|
|
185
|
+
attrs: KeyValue[];
|
|
186
|
+
startNs: string;
|
|
187
|
+
timeNs: string;
|
|
188
|
+
value: number;
|
|
189
|
+
count: number;
|
|
190
|
+
sum: number;
|
|
191
|
+
min: number;
|
|
192
|
+
max: number;
|
|
193
|
+
buckets: number[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
type InstrumentKind = 'counter' | 'gauge' | 'histogram';
|
|
197
|
+
|
|
198
|
+
/** Default ms histogram bounds: sub-ms through 10s. */
|
|
199
|
+
export const MS_BOUNDS = [0, 1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
200
|
+
|
|
201
|
+
const seriesKey = (attrs: KeyValue[]): string =>
|
|
202
|
+
attrs.map((a) => `${a.key}=${JSON.stringify(a.value)}`).join('|');
|
|
203
|
+
|
|
204
|
+
class Instrument {
|
|
205
|
+
readonly series = new Map<string, Series>();
|
|
206
|
+
|
|
207
|
+
constructor(
|
|
208
|
+
readonly name: string,
|
|
209
|
+
readonly unit: string,
|
|
210
|
+
readonly description: string,
|
|
211
|
+
readonly kind: InstrumentKind,
|
|
212
|
+
readonly int: boolean,
|
|
213
|
+
private readonly maxSeries: number,
|
|
214
|
+
readonly bounds: number[] = MS_BOUNDS,
|
|
215
|
+
) {}
|
|
216
|
+
|
|
217
|
+
private at(attrs: KeyValue[], nowNs: string): Series | undefined {
|
|
218
|
+
const k = seriesKey(attrs);
|
|
219
|
+
let s = this.series.get(k);
|
|
220
|
+
if (!s) {
|
|
221
|
+
// Cardinality cap: new series beyond it are dropped, existing ones keep counting.
|
|
222
|
+
if (this.series.size >= this.maxSeries) return undefined;
|
|
223
|
+
s = {
|
|
224
|
+
attrs, startNs: nowNs, timeNs: nowNs, value: 0, count: 0, sum: 0,
|
|
225
|
+
min: Infinity, max: -Infinity, buckets: new Array(this.bounds.length + 1).fill(0),
|
|
226
|
+
};
|
|
227
|
+
this.series.set(k, s);
|
|
228
|
+
}
|
|
229
|
+
return s;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
add(attrs: KeyValue[], v: number, nowNs: string): void {
|
|
233
|
+
const s = this.at(attrs, nowNs);
|
|
234
|
+
if (s) s.value += v;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
set(attrs: KeyValue[], v: number, nowNs: string): void {
|
|
238
|
+
const s = this.at(attrs, nowNs);
|
|
239
|
+
if (s) {
|
|
240
|
+
s.value = v;
|
|
241
|
+
s.timeNs = nowNs;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
record(attrs: KeyValue[], v: number, nowNs: string): void {
|
|
246
|
+
const s = this.at(attrs, nowNs);
|
|
247
|
+
if (!s) return;
|
|
248
|
+
s.count++;
|
|
249
|
+
s.sum += v;
|
|
250
|
+
if (v < s.min) s.min = v;
|
|
251
|
+
if (v > s.max) s.max = v;
|
|
252
|
+
// Buckets are upper-inclusive: (bounds[i-1], bounds[i]].
|
|
253
|
+
let i = 0;
|
|
254
|
+
while (i < this.bounds.length && v > this.bounds[i]) i++;
|
|
255
|
+
s.buckets[i]++;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
delete(attrs: KeyValue[]): void {
|
|
259
|
+
this.series.delete(seriesKey(attrs));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
encode(nowNs: string): OtlpMetric | null {
|
|
263
|
+
if (this.series.size === 0) return null;
|
|
264
|
+
const base = { name: this.name, unit: this.unit, description: this.description };
|
|
265
|
+
const num = (s: Series): Pick<OtlpNumberPoint, 'asInt' | 'asDouble'> =>
|
|
266
|
+
this.int ? { asInt: String(Math.trunc(s.value)) } : { asDouble: s.value };
|
|
267
|
+
const all = [...this.series.values()];
|
|
268
|
+
if (this.kind === 'counter') {
|
|
269
|
+
return {
|
|
270
|
+
...base,
|
|
271
|
+
sum: {
|
|
272
|
+
aggregationTemporality: TEMPORALITY_CUMULATIVE,
|
|
273
|
+
isMonotonic: true,
|
|
274
|
+
dataPoints: all.map((s) => ({ attributes: s.attrs, startTimeUnixNano: s.startNs, timeUnixNano: nowNs, ...num(s) })),
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (this.kind === 'gauge') {
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
gauge: { dataPoints: all.map((s) => ({ attributes: s.attrs, timeUnixNano: s.timeNs, ...num(s) })) },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const points = all.filter((s) => s.count > 0);
|
|
285
|
+
if (points.length === 0) return null;
|
|
286
|
+
return {
|
|
287
|
+
...base,
|
|
288
|
+
histogram: {
|
|
289
|
+
aggregationTemporality: TEMPORALITY_CUMULATIVE,
|
|
290
|
+
dataPoints: points.map((s) => ({
|
|
291
|
+
attributes: s.attrs,
|
|
292
|
+
startTimeUnixNano: s.startNs,
|
|
293
|
+
timeUnixNano: nowNs,
|
|
294
|
+
count: String(s.count),
|
|
295
|
+
sum: s.sum,
|
|
296
|
+
min: s.min,
|
|
297
|
+
max: s.max,
|
|
298
|
+
bucketCounts: s.buckets.map(String),
|
|
299
|
+
explicitBounds: this.bounds,
|
|
300
|
+
})),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ── the mapper ──────────────────────────────────────────────────────────── */
|
|
307
|
+
|
|
308
|
+
export interface OtelSignals {
|
|
309
|
+
traces?: boolean;
|
|
310
|
+
metrics?: boolean;
|
|
311
|
+
logs?: boolean;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export interface OtelMapperOptions {
|
|
315
|
+
/** Resource attributes (service.name, telemetry.sdk.*, …). */
|
|
316
|
+
resource: KeyValue[];
|
|
317
|
+
/** performance.timeOrigin of the main thread, when events come from this process. */
|
|
318
|
+
originMs?: number;
|
|
319
|
+
signals?: OtelSignals;
|
|
320
|
+
/** net:fetch URLs to skip: the exporter's own OTLP posts. */
|
|
321
|
+
ignoreUrl?: (url: string) => boolean;
|
|
322
|
+
/** Queued spans / log records each, oldest dropped past this (default 2048). */
|
|
323
|
+
maxQueue?: number;
|
|
324
|
+
/** Open task calls tracked for correlation (default 10_000, oldest dropped). */
|
|
325
|
+
maxPending?: number;
|
|
326
|
+
/** Series per metric before new attribute sets are dropped (default 2000). */
|
|
327
|
+
maxSeries?: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface PendingCall {
|
|
331
|
+
poolId: string;
|
|
332
|
+
taskId: string;
|
|
333
|
+
startMs: number;
|
|
334
|
+
slot?: number;
|
|
335
|
+
waitMs?: number;
|
|
336
|
+
argBytes?: number;
|
|
337
|
+
dispatchMs?: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const ERROR_OUTCOMES = new Set<TaskSettleOutcome>(['error', 'timeout', 'queue-full', 'crashed']);
|
|
341
|
+
|
|
342
|
+
const KNOWN_METHODS = new Set(['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE', 'QUERY']);
|
|
343
|
+
|
|
344
|
+
/** url.full must not carry credentials. */
|
|
345
|
+
const redactUrl = (url: string): string => url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, '$1REDACTED:REDACTED@');
|
|
346
|
+
|
|
347
|
+
const workerKey = (w?: { poolId: string; slot: number }): string | undefined =>
|
|
348
|
+
w ? `${w.poolId}#${w.slot}` : undefined;
|
|
349
|
+
|
|
350
|
+
export class OtelMapper {
|
|
351
|
+
readonly resource: KeyValue[];
|
|
352
|
+
readonly spans: OtlpSpan[] = [];
|
|
353
|
+
readonly logs: OtlpLogRecord[] = [];
|
|
354
|
+
/** Items dropped by the queue bound since creation. */
|
|
355
|
+
dropped = 0;
|
|
356
|
+
|
|
357
|
+
private readonly clock: ClockRebaser;
|
|
358
|
+
private readonly traces: boolean;
|
|
359
|
+
private readonly metricsOn: boolean;
|
|
360
|
+
private readonly logsOn: boolean;
|
|
361
|
+
private readonly maxQueue: number;
|
|
362
|
+
private readonly maxPending: number;
|
|
363
|
+
private readonly ignoreUrl?: (url: string) => boolean;
|
|
364
|
+
private readonly pending = new Map<string, PendingCall>();
|
|
365
|
+
private readonly pools = new Set<string>();
|
|
366
|
+
private readonly instruments: Instrument[] = [];
|
|
367
|
+
private readonly m: Record<
|
|
368
|
+
| 'settled' | 'duration' | 'wait' | 'inFlight' | 'islandOps' | 'heap' | 'rss'
|
|
369
|
+
| 'longFrames' | 'blocking' | 'fps' | 'writes' | 'workerErrors' | 'respawns',
|
|
370
|
+
Instrument
|
|
371
|
+
>;
|
|
372
|
+
|
|
373
|
+
constructor(opts: OtelMapperOptions) {
|
|
374
|
+
this.resource = opts.resource;
|
|
375
|
+
this.clock = new ClockRebaser(opts.originMs);
|
|
376
|
+
this.traces = opts.signals?.traces !== false;
|
|
377
|
+
this.metricsOn = opts.signals?.metrics !== false;
|
|
378
|
+
this.logsOn = opts.signals?.logs !== false;
|
|
379
|
+
this.maxQueue = opts.maxQueue ?? 2048;
|
|
380
|
+
this.maxPending = opts.maxPending ?? 10_000;
|
|
381
|
+
this.ignoreUrl = opts.ignoreUrl;
|
|
382
|
+
const cap = opts.maxSeries ?? 2000;
|
|
383
|
+
const mk = (name: string, unit: string, description: string, kind: InstrumentKind, int: boolean) => {
|
|
384
|
+
const i = new Instrument(name, unit, description, kind, int, cap);
|
|
385
|
+
this.instruments.push(i);
|
|
386
|
+
return i;
|
|
387
|
+
};
|
|
388
|
+
this.m = {
|
|
389
|
+
settled: mk('atoll.task.settled', '{call}', 'Task calls settled, by outcome', 'counter', true),
|
|
390
|
+
duration: mk('atoll.task.duration', 'ms', 'Task run time, dispatch to reply', 'histogram', false),
|
|
391
|
+
wait: mk('atoll.task.queue_wait', 'ms', 'Task queue wait, enqueue to dispatch', 'histogram', false),
|
|
392
|
+
inFlight: mk('atoll.task.in_flight', '{call}', 'Task calls enqueued and not yet settled', 'gauge', true),
|
|
393
|
+
islandOps: mk('atoll.island.ops', '{op}', 'Island DOM ops replayed on the main thread', 'counter', true),
|
|
394
|
+
heap: mk('atoll.runtime.heap', 'By', 'JS heap in use, per thread', 'gauge', true),
|
|
395
|
+
rss: mk('atoll.runtime.rss', 'By', 'Resident set size, per thread (Node)', 'gauge', true),
|
|
396
|
+
longFrames: mk('atoll.runtime.long_frames', '{frame}', 'Main-thread frames over the 50ms budget', 'counter', true),
|
|
397
|
+
blocking: mk('atoll.runtime.blocking', 'ms', 'Main-thread time past the 50ms frame budget', 'counter', false),
|
|
398
|
+
fps: mk('atoll.runtime.fps', '{frame}/s', 'Main-thread frame rate', 'gauge', false),
|
|
399
|
+
writes: mk('atoll.memory.writes', '{write}', 'Shared-memory field writes', 'counter', true),
|
|
400
|
+
workerErrors: mk('atoll.worker.errors', '{error}', 'Worker error events', 'counter', true),
|
|
401
|
+
respawns: mk('atoll.worker.respawns', '{respawn}', 'Crashed workers respawned', 'counter', true),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private pushSpan(s: OtlpSpan): void {
|
|
406
|
+
this.spans.push(s);
|
|
407
|
+
if (this.spans.length > this.maxQueue) {
|
|
408
|
+
this.spans.shift();
|
|
409
|
+
this.dropped++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private pushLog(r: OtlpLogRecord): void {
|
|
414
|
+
this.logs.push(r);
|
|
415
|
+
if (this.logs.length > this.maxQueue) {
|
|
416
|
+
this.logs.shift();
|
|
417
|
+
this.dropped++;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private log(
|
|
422
|
+
level: keyof typeof SEVERITY, body: string, attrs: KeyValue[], tMs: number, recvMs: number,
|
|
423
|
+
): void {
|
|
424
|
+
if (!this.logsOn) return;
|
|
425
|
+
this.pushLog({
|
|
426
|
+
timeUnixNano: toUnixNano(tMs),
|
|
427
|
+
observedTimeUnixNano: toUnixNano(recvMs),
|
|
428
|
+
severityNumber: SEVERITY[level],
|
|
429
|
+
severityText: level.toUpperCase(),
|
|
430
|
+
body: { stringValue: body },
|
|
431
|
+
attributes: attrs,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Thread attribution shared by logs and runtime metrics. */
|
|
436
|
+
private threadAttrs(ev: EmittedDevtoolsEvent): KeyValue[] {
|
|
437
|
+
const w = workerKey(ev.worker);
|
|
438
|
+
return w ? [attr.str('atoll.thread', 'worker'), attr.str('atoll.worker', w)] : [attr.str('atoll.thread', ev.thread)];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Feed one event; `recvEpochMs` is when the sink saw it (rebases foreign clocks). */
|
|
442
|
+
ingest(ev: EmittedDevtoolsEvent, recvEpochMs: number): void {
|
|
443
|
+
const t = this.clock.epochMs(ev, recvEpochMs);
|
|
444
|
+
const ns = () => toUnixNano(t);
|
|
445
|
+
switch (ev.type) {
|
|
446
|
+
case 'pool:init':
|
|
447
|
+
this.pools.add(ev.poolId);
|
|
448
|
+
return;
|
|
449
|
+
case 'pool:terminate':
|
|
450
|
+
this.pools.delete(ev.poolId);
|
|
451
|
+
if (this.metricsOn) this.m.inFlight.delete([attr.str('atoll.pool.id', ev.poolId)]);
|
|
452
|
+
return;
|
|
453
|
+
case 'task:enqueue': {
|
|
454
|
+
if (!this.traces && !this.metricsOn) return;
|
|
455
|
+
this.pools.add(ev.poolId);
|
|
456
|
+
this.pending.set(`${ev.poolId}:${ev.callId}`, { poolId: ev.poolId, taskId: ev.taskId, startMs: t });
|
|
457
|
+
if (this.pending.size > this.maxPending) this.pending.delete(this.pending.keys().next().value!);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
case 'task:dispatch': {
|
|
461
|
+
const p = this.pending.get(`${ev.poolId}:${ev.callId}`);
|
|
462
|
+
if (p) {
|
|
463
|
+
p.slot = ev.slot;
|
|
464
|
+
p.waitMs = ev.waitMs;
|
|
465
|
+
p.argBytes = ev.argBytes;
|
|
466
|
+
p.dispatchMs = t;
|
|
467
|
+
}
|
|
468
|
+
if (this.metricsOn) {
|
|
469
|
+
this.m.wait.record([attr.str('atoll.pool.id', ev.poolId), attr.str('atoll.task.id', ev.taskId)], ev.waitMs, ns());
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
case 'task:settle': {
|
|
474
|
+
const key = `${ev.poolId}:${ev.callId}`;
|
|
475
|
+
const p = this.pending.get(key);
|
|
476
|
+
this.pending.delete(key);
|
|
477
|
+
if (this.metricsOn) {
|
|
478
|
+
const base = [attr.str('atoll.pool.id', ev.poolId), attr.str('atoll.task.id', ev.taskId)];
|
|
479
|
+
this.m.settled.add([...base, attr.str('atoll.outcome', ev.outcome)], 1, ns());
|
|
480
|
+
if (ev.runMs !== undefined) this.m.duration.record(base, ev.runMs, ns());
|
|
481
|
+
}
|
|
482
|
+
if (!this.traces) return;
|
|
483
|
+
const attributes = [
|
|
484
|
+
attr.str('atoll.pool.id', ev.poolId),
|
|
485
|
+
attr.str('atoll.task.id', ev.taskId),
|
|
486
|
+
attr.str('atoll.outcome', ev.outcome),
|
|
487
|
+
];
|
|
488
|
+
if (p?.slot !== undefined) attributes.push(attr.int('atoll.worker.slot', p.slot));
|
|
489
|
+
if (p?.waitMs !== undefined) attributes.push(attr.dbl('atoll.queue_wait_ms', p.waitMs));
|
|
490
|
+
if (ev.runMs !== undefined) attributes.push(attr.dbl('atoll.run_ms', ev.runMs));
|
|
491
|
+
if (p?.argBytes !== undefined) attributes.push(attr.int('atoll.arg_bytes', p.argBytes));
|
|
492
|
+
if (ev.resultBytes !== undefined) attributes.push(attr.int('atoll.result_bytes', ev.resultBytes));
|
|
493
|
+
const fwd = workerKey(ev.worker);
|
|
494
|
+
if (fwd) attributes.push(attr.str('atoll.worker', fwd));
|
|
495
|
+
const span: OtlpSpan = {
|
|
496
|
+
traceId: newTraceId(),
|
|
497
|
+
spanId: newSpanId(),
|
|
498
|
+
name: `atoll.task ${ev.taskId}`,
|
|
499
|
+
kind: SPAN_KIND_CLIENT,
|
|
500
|
+
startTimeUnixNano: toUnixNano(p?.startMs ?? t - (ev.runMs ?? 0)),
|
|
501
|
+
endTimeUnixNano: ns(),
|
|
502
|
+
attributes,
|
|
503
|
+
};
|
|
504
|
+
if (p?.dispatchMs !== undefined) {
|
|
505
|
+
span.events = [{
|
|
506
|
+
timeUnixNano: toUnixNano(p.dispatchMs),
|
|
507
|
+
name: 'atoll.dispatch',
|
|
508
|
+
attributes: [attr.int('atoll.worker.slot', p.slot ?? -1), attr.dbl('atoll.queue_wait_ms', p.waitMs ?? 0)],
|
|
509
|
+
}];
|
|
510
|
+
}
|
|
511
|
+
if (ERROR_OUTCOMES.has(ev.outcome)) span.status = { code: STATUS_ERROR, message: ev.error ?? ev.outcome };
|
|
512
|
+
this.pushSpan(span);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
case 'island:task': {
|
|
516
|
+
if (!this.traces) return;
|
|
517
|
+
const attributes = [attr.str('atoll.island.instance', ev.instance), attr.str('atoll.island.method', ev.method)];
|
|
518
|
+
if (ev.ops !== undefined) attributes.push(attr.int('atoll.island.ops', ev.ops));
|
|
519
|
+
const span: OtlpSpan = {
|
|
520
|
+
traceId: newTraceId(),
|
|
521
|
+
spanId: newSpanId(),
|
|
522
|
+
name: `atoll.island ${ev.instance} ${ev.method}`,
|
|
523
|
+
kind: SPAN_KIND_CLIENT,
|
|
524
|
+
startTimeUnixNano: toUnixNano(t - ev.ms),
|
|
525
|
+
endTimeUnixNano: ns(),
|
|
526
|
+
attributes,
|
|
527
|
+
};
|
|
528
|
+
if (ev.error !== undefined) span.status = { code: STATUS_ERROR, message: ev.error };
|
|
529
|
+
this.pushSpan(span);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
case 'island:ops':
|
|
533
|
+
if (this.metricsOn) {
|
|
534
|
+
this.m.islandOps.add(
|
|
535
|
+
[attr.str('atoll.island.instance', ev.instance), attr.str('atoll.island.via', ev.via)], ev.count, ns(),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
case 'net:fetch': {
|
|
540
|
+
if (!this.traces || this.ignoreUrl?.(ev.url)) return;
|
|
541
|
+
const known = KNOWN_METHODS.has(ev.method);
|
|
542
|
+
const attributes = [attr.str('http.request.method', known ? ev.method : '_OTHER')];
|
|
543
|
+
if (!known) attributes.push(attr.str('http.request.method_original', ev.method));
|
|
544
|
+
attributes.push(attr.str('url.full', redactUrl(ev.url)));
|
|
545
|
+
try {
|
|
546
|
+
const u = new URL(ev.url);
|
|
547
|
+
attributes.push(attr.str('server.address', u.hostname));
|
|
548
|
+
if (u.port) attributes.push(attr.int('server.port', Number(u.port)));
|
|
549
|
+
} catch {
|
|
550
|
+
/* relative URL: no server.address */
|
|
551
|
+
}
|
|
552
|
+
if (ev.status !== undefined) attributes.push(attr.int('http.response.status_code', ev.status));
|
|
553
|
+
if (ev.error !== undefined) attributes.push(attr.str('error.type', '_OTHER'));
|
|
554
|
+
else if (ev.status !== undefined && ev.status >= 400) attributes.push(attr.str('error.type', String(ev.status)));
|
|
555
|
+
const fwd = workerKey(ev.worker);
|
|
556
|
+
if (fwd) attributes.push(attr.str('atoll.worker', fwd));
|
|
557
|
+
const span: OtlpSpan = {
|
|
558
|
+
traceId: newTraceId(),
|
|
559
|
+
spanId: newSpanId(),
|
|
560
|
+
name: known ? ev.method : 'HTTP',
|
|
561
|
+
kind: SPAN_KIND_CLIENT,
|
|
562
|
+
startTimeUnixNano: toUnixNano(t - ev.ms),
|
|
563
|
+
endTimeUnixNano: ns(),
|
|
564
|
+
attributes,
|
|
565
|
+
};
|
|
566
|
+
if (ev.error !== undefined) span.status = { code: STATUS_ERROR, message: ev.error };
|
|
567
|
+
else if (ev.status !== undefined && ev.status >= 400) span.status = { code: STATUS_ERROR };
|
|
568
|
+
this.pushSpan(span);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
case 'runtime:memory':
|
|
572
|
+
if (this.metricsOn) {
|
|
573
|
+
const a = this.threadAttrs(ev);
|
|
574
|
+
this.m.heap.set(a, ev.heapBytes, ns());
|
|
575
|
+
if (ev.rssBytes !== undefined) this.m.rss.set(a, ev.rssBytes, ns());
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
case 'runtime:longframe':
|
|
579
|
+
if (this.metricsOn) {
|
|
580
|
+
this.m.longFrames.add([], 1, ns());
|
|
581
|
+
this.m.blocking.add([], ev.blockingMs, ns());
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
case 'runtime:frames':
|
|
585
|
+
if (this.metricsOn) this.m.fps.set([], ev.fps, ns());
|
|
586
|
+
return;
|
|
587
|
+
case 'memory:write':
|
|
588
|
+
if (this.metricsOn) this.m.writes.add([attr.str('atoll.memory.field', ev.path)], 1, ns());
|
|
589
|
+
return;
|
|
590
|
+
case 'worker:error': {
|
|
591
|
+
const a = [attr.str('atoll.pool.id', ev.poolId), attr.int('atoll.worker.slot', ev.slot)];
|
|
592
|
+
if (this.metricsOn) this.m.workerErrors.add([a[0]], 1, ns());
|
|
593
|
+
this.log('error', `worker error: ${ev.message}`, [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
case 'worker:respawn': {
|
|
597
|
+
const a = [attr.str('atoll.pool.id', ev.poolId), attr.int('atoll.worker.slot', ev.slot)];
|
|
598
|
+
if (this.metricsOn) this.m.respawns.add([a[0]], 1, ns());
|
|
599
|
+
this.log('info', 'worker respawned', [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
case 'log': {
|
|
603
|
+
const a = [attr.str('atoll.log.scope', ev.scope), ...this.threadAttrs(ev)];
|
|
604
|
+
if (ev.data !== undefined) a.push(attr.str('atoll.log.data', ev.data));
|
|
605
|
+
this.log(ev.level, ev.message, a, t, recvEpochMs);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
default:
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Remove up to `max` queued spans. */
|
|
614
|
+
takeSpans(max: number): OtlpSpan[] {
|
|
615
|
+
return this.spans.splice(0, max);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Remove up to `max` queued log records. */
|
|
619
|
+
takeLogs(max: number): OtlpLogRecord[] {
|
|
620
|
+
return this.logs.splice(0, max);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Cumulative snapshot of every metric with data; empty when metrics are off. */
|
|
624
|
+
collectMetrics(nowEpochMs: number): OtlpMetric[] {
|
|
625
|
+
if (!this.metricsOn) return [];
|
|
626
|
+
const nowNs = toUnixNano(nowEpochMs);
|
|
627
|
+
const inFlight = new Map<string, number>();
|
|
628
|
+
for (const id of this.pools) inFlight.set(id, 0);
|
|
629
|
+
for (const p of this.pending.values()) inFlight.set(p.poolId, (inFlight.get(p.poolId) ?? 0) + 1);
|
|
630
|
+
for (const [id, n] of inFlight) this.m.inFlight.set([attr.str('atoll.pool.id', id)], n, nowNs);
|
|
631
|
+
const out: OtlpMetric[] = [];
|
|
632
|
+
for (const i of this.instruments) {
|
|
633
|
+
const m = i.encode(nowNs);
|
|
634
|
+
if (m) out.push(m);
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* ── request bodies ──────────────────────────────────────────────────────── */
|
|
641
|
+
|
|
642
|
+
export const encodeTraces = (groups: { resource: KeyValue[]; spans: OtlpSpan[] }[]) => ({
|
|
643
|
+
resourceSpans: groups.map((g) => ({
|
|
644
|
+
resource: { attributes: g.resource },
|
|
645
|
+
scopeSpans: [{ scope: OTEL_SCOPE, spans: g.spans }],
|
|
646
|
+
})),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
export const encodeMetrics = (groups: { resource: KeyValue[]; metrics: OtlpMetric[] }[]) => ({
|
|
650
|
+
resourceMetrics: groups.map((g) => ({
|
|
651
|
+
resource: { attributes: g.resource },
|
|
652
|
+
scopeMetrics: [{ scope: OTEL_SCOPE, metrics: g.metrics }],
|
|
653
|
+
})),
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
export const encodeLogs = (groups: { resource: KeyValue[]; logs: OtlpLogRecord[] }[]) => ({
|
|
657
|
+
resourceLogs: groups.map((g) => ({
|
|
658
|
+
resource: { attributes: g.resource },
|
|
659
|
+
scopeLogs: [{ scope: OTEL_SCOPE, logRecords: g.logs }],
|
|
660
|
+
})),
|
|
661
|
+
});
|