@atolljs/devtools 0.1.7 → 0.1.9

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.
Files changed (50) hide show
  1. package/README.md +408 -71
  2. package/app/api.js +68 -0
  3. package/app/fw-logos.js +14 -0
  4. package/app/index.html +161 -2
  5. package/app/main.js +897 -222
  6. package/app/panels/actions.js +324 -0
  7. package/app/panels/audit-rules.js +728 -0
  8. package/app/panels/audits.js +438 -0
  9. package/app/panels/inspect-island.js +351 -0
  10. package/app/panels/memory-values.js +287 -0
  11. package/app/panels/performance.js +304 -0
  12. package/app/panels/reactivity.js +271 -0
  13. package/app/panels/recorder.js +344 -0
  14. package/app/panels/recording-format.js +166 -0
  15. package/app/panels/route.d.ts +44 -0
  16. package/app/panels/route.js +264 -0
  17. package/app/panels/shell.js +789 -0
  18. package/app/panels/toast.js +38 -0
  19. package/dist/bin.js +704 -5
  20. package/dist/chunk-57NGHQUL.js +344 -0
  21. package/dist/chunk-6E54FXKS.js +495 -0
  22. package/dist/chunk-WIL2P7HG.js +661 -0
  23. package/dist/client.d.ts +7 -0
  24. package/dist/control.d.ts +4 -0
  25. package/dist/index.js +2 -1
  26. package/dist/node.js +2 -1
  27. package/dist/nodeWs.d.ts +1 -0
  28. package/dist/otel.d.ts +35 -0
  29. package/dist/otel.js +87 -0
  30. package/dist/otelMap.d.ts +232 -0
  31. package/dist/otelTransport.d.ts +69 -0
  32. package/dist/overlay.d.ts +16 -2
  33. package/dist/probes.d.ts +7 -0
  34. package/dist/protocol.d.ts +46 -4
  35. package/dist/server.d.ts +19 -0
  36. package/dist/server.js +61 -3
  37. package/package.json +6 -2
  38. package/src/bin.ts +27 -2
  39. package/src/broadcast.ts +6 -0
  40. package/src/client.ts +26 -2
  41. package/src/control.ts +51 -0
  42. package/src/nodeWs.ts +7 -1
  43. package/src/otel.ts +140 -0
  44. package/src/otelMap.ts +661 -0
  45. package/src/otelTransport.ts +286 -0
  46. package/src/overlay.ts +239 -47
  47. package/src/probes.ts +155 -0
  48. package/src/protocol.ts +52 -4
  49. package/src/server.ts +83 -3
  50. package/dist/chunk-4N3NBFL3.js +0 -474
@@ -0,0 +1,661 @@
1
+ // src/otelMap.ts
2
+ var SPAN_KIND_CLIENT = 3;
3
+ var STATUS_ERROR = 2;
4
+ var TEMPORALITY_CUMULATIVE = 2;
5
+ var SEVERITY = {
6
+ trace: 1,
7
+ debug: 5,
8
+ info: 9,
9
+ warn: 13,
10
+ error: 17
11
+ };
12
+ var OTEL_SCOPE = { name: "@atolljs/devtools" };
13
+ var attr = {
14
+ str: (key, v) => ({ key, value: { stringValue: v } }),
15
+ int: (key, v) => ({ key, value: { intValue: String(Math.trunc(v)) } }),
16
+ dbl: (key, v) => ({ key, value: { doubleValue: v } }),
17
+ bool: (key, v) => ({ key, value: { boolValue: v } })
18
+ };
19
+ var toAttributes = (bag) => {
20
+ const out = [];
21
+ for (const [k, v] of Object.entries(bag)) {
22
+ if (v === void 0) continue;
23
+ if (typeof v === "string") out.push(attr.str(k, v));
24
+ else if (typeof v === "boolean") out.push(attr.bool(k, v));
25
+ else if (Number.isSafeInteger(v)) out.push(attr.int(k, v));
26
+ else if (Number.isFinite(v)) out.push(attr.dbl(k, v));
27
+ }
28
+ return out;
29
+ };
30
+ var toUnixNano = (epochMs) => {
31
+ let whole = Math.floor(epochMs);
32
+ let frac = Math.round((epochMs - whole) * 1e6);
33
+ if (frac >= 1e6) {
34
+ whole += 1;
35
+ frac -= 1e6;
36
+ }
37
+ return (BigInt(whole) * 1000000n + BigInt(frac)).toString();
38
+ };
39
+ var hex = (bytes) => {
40
+ const a = new Uint8Array(bytes);
41
+ const c = globalThis.crypto;
42
+ if (c?.getRandomValues) c.getRandomValues(a);
43
+ else for (let i = 0; i < bytes; i++) a[i] = Math.random() * 256 | 0;
44
+ if (a.every((b) => b === 0)) a[bytes - 1] = 1;
45
+ let s = "";
46
+ for (const b of a) s += b.toString(16).padStart(2, "0");
47
+ return s;
48
+ };
49
+ var newTraceId = () => hex(16);
50
+ var newSpanId = () => hex(8);
51
+ var ClockRebaser = class {
52
+ constructor(originMs, resetMs = 1e3) {
53
+ this.originMs = originMs;
54
+ this.resetMs = resetMs;
55
+ }
56
+ originMs;
57
+ resetMs;
58
+ offsets = /* @__PURE__ */ new Map();
59
+ epochMs(ev, recvEpochMs) {
60
+ if (!ev.worker && this.originMs !== void 0) return this.originMs + ev.at;
61
+ const key = ev.worker ? `${ev.worker.poolId}#${ev.worker.slot}` : ev.thread;
62
+ const d = recvEpochMs - ev.at;
63
+ const cur = this.offsets.get(key);
64
+ const off = cur === void 0 || d < cur || d - cur > this.resetMs ? d : cur;
65
+ this.offsets.set(key, off);
66
+ return ev.at + off;
67
+ }
68
+ };
69
+ var MS_BOUNDS = [0, 1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4];
70
+ var seriesKey = (attrs) => attrs.map((a) => `${a.key}=${JSON.stringify(a.value)}`).join("|");
71
+ var Instrument = class {
72
+ constructor(name, unit, description, kind, int, maxSeries, bounds = MS_BOUNDS) {
73
+ this.name = name;
74
+ this.unit = unit;
75
+ this.description = description;
76
+ this.kind = kind;
77
+ this.int = int;
78
+ this.maxSeries = maxSeries;
79
+ this.bounds = bounds;
80
+ }
81
+ name;
82
+ unit;
83
+ description;
84
+ kind;
85
+ int;
86
+ maxSeries;
87
+ bounds;
88
+ series = /* @__PURE__ */ new Map();
89
+ at(attrs, nowNs) {
90
+ const k = seriesKey(attrs);
91
+ let s = this.series.get(k);
92
+ if (!s) {
93
+ if (this.series.size >= this.maxSeries) return void 0;
94
+ s = {
95
+ attrs,
96
+ startNs: nowNs,
97
+ timeNs: nowNs,
98
+ value: 0,
99
+ count: 0,
100
+ sum: 0,
101
+ min: Infinity,
102
+ max: -Infinity,
103
+ buckets: new Array(this.bounds.length + 1).fill(0)
104
+ };
105
+ this.series.set(k, s);
106
+ }
107
+ return s;
108
+ }
109
+ add(attrs, v, nowNs) {
110
+ const s = this.at(attrs, nowNs);
111
+ if (s) s.value += v;
112
+ }
113
+ set(attrs, v, nowNs) {
114
+ const s = this.at(attrs, nowNs);
115
+ if (s) {
116
+ s.value = v;
117
+ s.timeNs = nowNs;
118
+ }
119
+ }
120
+ record(attrs, v, nowNs) {
121
+ const s = this.at(attrs, nowNs);
122
+ if (!s) return;
123
+ s.count++;
124
+ s.sum += v;
125
+ if (v < s.min) s.min = v;
126
+ if (v > s.max) s.max = v;
127
+ let i = 0;
128
+ while (i < this.bounds.length && v > this.bounds[i]) i++;
129
+ s.buckets[i]++;
130
+ }
131
+ delete(attrs) {
132
+ this.series.delete(seriesKey(attrs));
133
+ }
134
+ encode(nowNs) {
135
+ if (this.series.size === 0) return null;
136
+ const base = { name: this.name, unit: this.unit, description: this.description };
137
+ const num = (s) => this.int ? { asInt: String(Math.trunc(s.value)) } : { asDouble: s.value };
138
+ const all = [...this.series.values()];
139
+ if (this.kind === "counter") {
140
+ return {
141
+ ...base,
142
+ sum: {
143
+ aggregationTemporality: TEMPORALITY_CUMULATIVE,
144
+ isMonotonic: true,
145
+ dataPoints: all.map((s) => ({ attributes: s.attrs, startTimeUnixNano: s.startNs, timeUnixNano: nowNs, ...num(s) }))
146
+ }
147
+ };
148
+ }
149
+ if (this.kind === "gauge") {
150
+ return {
151
+ ...base,
152
+ gauge: { dataPoints: all.map((s) => ({ attributes: s.attrs, timeUnixNano: s.timeNs, ...num(s) })) }
153
+ };
154
+ }
155
+ const points = all.filter((s) => s.count > 0);
156
+ if (points.length === 0) return null;
157
+ return {
158
+ ...base,
159
+ histogram: {
160
+ aggregationTemporality: TEMPORALITY_CUMULATIVE,
161
+ dataPoints: points.map((s) => ({
162
+ attributes: s.attrs,
163
+ startTimeUnixNano: s.startNs,
164
+ timeUnixNano: nowNs,
165
+ count: String(s.count),
166
+ sum: s.sum,
167
+ min: s.min,
168
+ max: s.max,
169
+ bucketCounts: s.buckets.map(String),
170
+ explicitBounds: this.bounds
171
+ }))
172
+ }
173
+ };
174
+ }
175
+ };
176
+ var ERROR_OUTCOMES = /* @__PURE__ */ new Set(["error", "timeout", "queue-full", "crashed"]);
177
+ var KNOWN_METHODS = /* @__PURE__ */ new Set(["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", "QUERY"]);
178
+ var redactUrl = (url) => url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, "$1REDACTED:REDACTED@");
179
+ var workerKey = (w) => w ? `${w.poolId}#${w.slot}` : void 0;
180
+ var OtelMapper = class {
181
+ resource;
182
+ spans = [];
183
+ logs = [];
184
+ /** Items dropped by the queue bound since creation. */
185
+ dropped = 0;
186
+ clock;
187
+ traces;
188
+ metricsOn;
189
+ logsOn;
190
+ maxQueue;
191
+ maxPending;
192
+ ignoreUrl;
193
+ pending = /* @__PURE__ */ new Map();
194
+ pools = /* @__PURE__ */ new Set();
195
+ instruments = [];
196
+ m;
197
+ constructor(opts) {
198
+ this.resource = opts.resource;
199
+ this.clock = new ClockRebaser(opts.originMs);
200
+ this.traces = opts.signals?.traces !== false;
201
+ this.metricsOn = opts.signals?.metrics !== false;
202
+ this.logsOn = opts.signals?.logs !== false;
203
+ this.maxQueue = opts.maxQueue ?? 2048;
204
+ this.maxPending = opts.maxPending ?? 1e4;
205
+ this.ignoreUrl = opts.ignoreUrl;
206
+ const cap = opts.maxSeries ?? 2e3;
207
+ const mk = (name, unit, description, kind, int) => {
208
+ const i = new Instrument(name, unit, description, kind, int, cap);
209
+ this.instruments.push(i);
210
+ return i;
211
+ };
212
+ this.m = {
213
+ settled: mk("atoll.task.settled", "{call}", "Task calls settled, by outcome", "counter", true),
214
+ duration: mk("atoll.task.duration", "ms", "Task run time, dispatch to reply", "histogram", false),
215
+ wait: mk("atoll.task.queue_wait", "ms", "Task queue wait, enqueue to dispatch", "histogram", false),
216
+ inFlight: mk("atoll.task.in_flight", "{call}", "Task calls enqueued and not yet settled", "gauge", true),
217
+ islandOps: mk("atoll.island.ops", "{op}", "Island DOM ops replayed on the main thread", "counter", true),
218
+ heap: mk("atoll.runtime.heap", "By", "JS heap in use, per thread", "gauge", true),
219
+ rss: mk("atoll.runtime.rss", "By", "Resident set size, per thread (Node)", "gauge", true),
220
+ longFrames: mk("atoll.runtime.long_frames", "{frame}", "Main-thread frames over the 50ms budget", "counter", true),
221
+ blocking: mk("atoll.runtime.blocking", "ms", "Main-thread time past the 50ms frame budget", "counter", false),
222
+ fps: mk("atoll.runtime.fps", "{frame}/s", "Main-thread frame rate", "gauge", false),
223
+ writes: mk("atoll.memory.writes", "{write}", "Shared-memory field writes", "counter", true),
224
+ workerErrors: mk("atoll.worker.errors", "{error}", "Worker error events", "counter", true),
225
+ respawns: mk("atoll.worker.respawns", "{respawn}", "Crashed workers respawned", "counter", true)
226
+ };
227
+ }
228
+ pushSpan(s) {
229
+ this.spans.push(s);
230
+ if (this.spans.length > this.maxQueue) {
231
+ this.spans.shift();
232
+ this.dropped++;
233
+ }
234
+ }
235
+ pushLog(r) {
236
+ this.logs.push(r);
237
+ if (this.logs.length > this.maxQueue) {
238
+ this.logs.shift();
239
+ this.dropped++;
240
+ }
241
+ }
242
+ log(level, body, attrs, tMs, recvMs) {
243
+ if (!this.logsOn) return;
244
+ this.pushLog({
245
+ timeUnixNano: toUnixNano(tMs),
246
+ observedTimeUnixNano: toUnixNano(recvMs),
247
+ severityNumber: SEVERITY[level],
248
+ severityText: level.toUpperCase(),
249
+ body: { stringValue: body },
250
+ attributes: attrs
251
+ });
252
+ }
253
+ /** Thread attribution shared by logs and runtime metrics. */
254
+ threadAttrs(ev) {
255
+ const w = workerKey(ev.worker);
256
+ return w ? [attr.str("atoll.thread", "worker"), attr.str("atoll.worker", w)] : [attr.str("atoll.thread", ev.thread)];
257
+ }
258
+ /** Feed one event; `recvEpochMs` is when the sink saw it (rebases foreign clocks). */
259
+ ingest(ev, recvEpochMs) {
260
+ const t = this.clock.epochMs(ev, recvEpochMs);
261
+ const ns = () => toUnixNano(t);
262
+ switch (ev.type) {
263
+ case "pool:init":
264
+ this.pools.add(ev.poolId);
265
+ return;
266
+ case "pool:terminate":
267
+ this.pools.delete(ev.poolId);
268
+ if (this.metricsOn) this.m.inFlight.delete([attr.str("atoll.pool.id", ev.poolId)]);
269
+ return;
270
+ case "task:enqueue": {
271
+ if (!this.traces && !this.metricsOn) return;
272
+ this.pools.add(ev.poolId);
273
+ this.pending.set(`${ev.poolId}:${ev.callId}`, { poolId: ev.poolId, taskId: ev.taskId, startMs: t });
274
+ if (this.pending.size > this.maxPending) this.pending.delete(this.pending.keys().next().value);
275
+ return;
276
+ }
277
+ case "task:dispatch": {
278
+ const p = this.pending.get(`${ev.poolId}:${ev.callId}`);
279
+ if (p) {
280
+ p.slot = ev.slot;
281
+ p.waitMs = ev.waitMs;
282
+ p.argBytes = ev.argBytes;
283
+ p.dispatchMs = t;
284
+ }
285
+ if (this.metricsOn) {
286
+ this.m.wait.record([attr.str("atoll.pool.id", ev.poolId), attr.str("atoll.task.id", ev.taskId)], ev.waitMs, ns());
287
+ }
288
+ return;
289
+ }
290
+ case "task:settle": {
291
+ const key = `${ev.poolId}:${ev.callId}`;
292
+ const p = this.pending.get(key);
293
+ this.pending.delete(key);
294
+ if (this.metricsOn) {
295
+ const base = [attr.str("atoll.pool.id", ev.poolId), attr.str("atoll.task.id", ev.taskId)];
296
+ this.m.settled.add([...base, attr.str("atoll.outcome", ev.outcome)], 1, ns());
297
+ if (ev.runMs !== void 0) this.m.duration.record(base, ev.runMs, ns());
298
+ }
299
+ if (!this.traces) return;
300
+ const attributes = [
301
+ attr.str("atoll.pool.id", ev.poolId),
302
+ attr.str("atoll.task.id", ev.taskId),
303
+ attr.str("atoll.outcome", ev.outcome)
304
+ ];
305
+ if (p?.slot !== void 0) attributes.push(attr.int("atoll.worker.slot", p.slot));
306
+ if (p?.waitMs !== void 0) attributes.push(attr.dbl("atoll.queue_wait_ms", p.waitMs));
307
+ if (ev.runMs !== void 0) attributes.push(attr.dbl("atoll.run_ms", ev.runMs));
308
+ if (p?.argBytes !== void 0) attributes.push(attr.int("atoll.arg_bytes", p.argBytes));
309
+ if (ev.resultBytes !== void 0) attributes.push(attr.int("atoll.result_bytes", ev.resultBytes));
310
+ const fwd = workerKey(ev.worker);
311
+ if (fwd) attributes.push(attr.str("atoll.worker", fwd));
312
+ const span = {
313
+ traceId: newTraceId(),
314
+ spanId: newSpanId(),
315
+ name: `atoll.task ${ev.taskId}`,
316
+ kind: SPAN_KIND_CLIENT,
317
+ startTimeUnixNano: toUnixNano(p?.startMs ?? t - (ev.runMs ?? 0)),
318
+ endTimeUnixNano: ns(),
319
+ attributes
320
+ };
321
+ if (p?.dispatchMs !== void 0) {
322
+ span.events = [{
323
+ timeUnixNano: toUnixNano(p.dispatchMs),
324
+ name: "atoll.dispatch",
325
+ attributes: [attr.int("atoll.worker.slot", p.slot ?? -1), attr.dbl("atoll.queue_wait_ms", p.waitMs ?? 0)]
326
+ }];
327
+ }
328
+ if (ERROR_OUTCOMES.has(ev.outcome)) span.status = { code: STATUS_ERROR, message: ev.error ?? ev.outcome };
329
+ this.pushSpan(span);
330
+ return;
331
+ }
332
+ case "island:task": {
333
+ if (!this.traces) return;
334
+ const attributes = [attr.str("atoll.island.instance", ev.instance), attr.str("atoll.island.method", ev.method)];
335
+ if (ev.ops !== void 0) attributes.push(attr.int("atoll.island.ops", ev.ops));
336
+ const span = {
337
+ traceId: newTraceId(),
338
+ spanId: newSpanId(),
339
+ name: `atoll.island ${ev.instance} ${ev.method}`,
340
+ kind: SPAN_KIND_CLIENT,
341
+ startTimeUnixNano: toUnixNano(t - ev.ms),
342
+ endTimeUnixNano: ns(),
343
+ attributes
344
+ };
345
+ if (ev.error !== void 0) span.status = { code: STATUS_ERROR, message: ev.error };
346
+ this.pushSpan(span);
347
+ return;
348
+ }
349
+ case "island:ops":
350
+ if (this.metricsOn) {
351
+ this.m.islandOps.add(
352
+ [attr.str("atoll.island.instance", ev.instance), attr.str("atoll.island.via", ev.via)],
353
+ ev.count,
354
+ ns()
355
+ );
356
+ }
357
+ return;
358
+ case "net:fetch": {
359
+ if (!this.traces || this.ignoreUrl?.(ev.url)) return;
360
+ const known = KNOWN_METHODS.has(ev.method);
361
+ const attributes = [attr.str("http.request.method", known ? ev.method : "_OTHER")];
362
+ if (!known) attributes.push(attr.str("http.request.method_original", ev.method));
363
+ attributes.push(attr.str("url.full", redactUrl(ev.url)));
364
+ try {
365
+ const u = new URL(ev.url);
366
+ attributes.push(attr.str("server.address", u.hostname));
367
+ if (u.port) attributes.push(attr.int("server.port", Number(u.port)));
368
+ } catch {
369
+ }
370
+ if (ev.status !== void 0) attributes.push(attr.int("http.response.status_code", ev.status));
371
+ if (ev.error !== void 0) attributes.push(attr.str("error.type", "_OTHER"));
372
+ else if (ev.status !== void 0 && ev.status >= 400) attributes.push(attr.str("error.type", String(ev.status)));
373
+ const fwd = workerKey(ev.worker);
374
+ if (fwd) attributes.push(attr.str("atoll.worker", fwd));
375
+ const span = {
376
+ traceId: newTraceId(),
377
+ spanId: newSpanId(),
378
+ name: known ? ev.method : "HTTP",
379
+ kind: SPAN_KIND_CLIENT,
380
+ startTimeUnixNano: toUnixNano(t - ev.ms),
381
+ endTimeUnixNano: ns(),
382
+ attributes
383
+ };
384
+ if (ev.error !== void 0) span.status = { code: STATUS_ERROR, message: ev.error };
385
+ else if (ev.status !== void 0 && ev.status >= 400) span.status = { code: STATUS_ERROR };
386
+ this.pushSpan(span);
387
+ return;
388
+ }
389
+ case "runtime:memory":
390
+ if (this.metricsOn) {
391
+ const a = this.threadAttrs(ev);
392
+ this.m.heap.set(a, ev.heapBytes, ns());
393
+ if (ev.rssBytes !== void 0) this.m.rss.set(a, ev.rssBytes, ns());
394
+ }
395
+ return;
396
+ case "runtime:longframe":
397
+ if (this.metricsOn) {
398
+ this.m.longFrames.add([], 1, ns());
399
+ this.m.blocking.add([], ev.blockingMs, ns());
400
+ }
401
+ return;
402
+ case "runtime:frames":
403
+ if (this.metricsOn) this.m.fps.set([], ev.fps, ns());
404
+ return;
405
+ case "memory:write":
406
+ if (this.metricsOn) this.m.writes.add([attr.str("atoll.memory.field", ev.path)], 1, ns());
407
+ return;
408
+ case "worker:error": {
409
+ const a = [attr.str("atoll.pool.id", ev.poolId), attr.int("atoll.worker.slot", ev.slot)];
410
+ if (this.metricsOn) this.m.workerErrors.add([a[0]], 1, ns());
411
+ this.log("error", `worker error: ${ev.message}`, [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
412
+ return;
413
+ }
414
+ case "worker:respawn": {
415
+ const a = [attr.str("atoll.pool.id", ev.poolId), attr.int("atoll.worker.slot", ev.slot)];
416
+ if (this.metricsOn) this.m.respawns.add([a[0]], 1, ns());
417
+ this.log("info", "worker respawned", [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
418
+ return;
419
+ }
420
+ case "log": {
421
+ const a = [attr.str("atoll.log.scope", ev.scope), ...this.threadAttrs(ev)];
422
+ if (ev.data !== void 0) a.push(attr.str("atoll.log.data", ev.data));
423
+ this.log(ev.level, ev.message, a, t, recvEpochMs);
424
+ return;
425
+ }
426
+ default:
427
+ return;
428
+ }
429
+ }
430
+ /** Remove up to `max` queued spans. */
431
+ takeSpans(max) {
432
+ return this.spans.splice(0, max);
433
+ }
434
+ /** Remove up to `max` queued log records. */
435
+ takeLogs(max) {
436
+ return this.logs.splice(0, max);
437
+ }
438
+ /** Cumulative snapshot of every metric with data; empty when metrics are off. */
439
+ collectMetrics(nowEpochMs) {
440
+ if (!this.metricsOn) return [];
441
+ const nowNs = toUnixNano(nowEpochMs);
442
+ const inFlight = /* @__PURE__ */ new Map();
443
+ for (const id of this.pools) inFlight.set(id, 0);
444
+ for (const p of this.pending.values()) inFlight.set(p.poolId, (inFlight.get(p.poolId) ?? 0) + 1);
445
+ for (const [id, n] of inFlight) this.m.inFlight.set([attr.str("atoll.pool.id", id)], n, nowNs);
446
+ const out = [];
447
+ for (const i of this.instruments) {
448
+ const m = i.encode(nowNs);
449
+ if (m) out.push(m);
450
+ }
451
+ return out;
452
+ }
453
+ };
454
+ var encodeTraces = (groups) => ({
455
+ resourceSpans: groups.map((g) => ({
456
+ resource: { attributes: g.resource },
457
+ scopeSpans: [{ scope: OTEL_SCOPE, spans: g.spans }]
458
+ }))
459
+ });
460
+ var encodeMetrics = (groups) => ({
461
+ resourceMetrics: groups.map((g) => ({
462
+ resource: { attributes: g.resource },
463
+ scopeMetrics: [{ scope: OTEL_SCOPE, metrics: g.metrics }]
464
+ }))
465
+ });
466
+ var encodeLogs = (groups) => ({
467
+ resourceLogs: groups.map((g) => ({
468
+ resource: { attributes: g.resource },
469
+ scopeLogs: [{ scope: OTEL_SCOPE, logRecords: g.logs }]
470
+ }))
471
+ });
472
+
473
+ // src/otelTransport.ts
474
+ var RETRYABLE = /* @__PURE__ */ new Set([429, 502, 503, 504]);
475
+ var KEEPALIVE_MAX = 6e4;
476
+ var sleep = (ms) => new Promise((r) => {
477
+ const t = setTimeout(r, ms);
478
+ t.unref?.();
479
+ });
480
+ var retryAfterMs = (h) => {
481
+ if (!h) return void 0;
482
+ const secs = Number(h);
483
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
484
+ const at = Date.parse(h);
485
+ return Number.isNaN(at) ? void 0 : Math.max(0, at - Date.now());
486
+ };
487
+ var normalizeEndpoint = (endpoint = "http://localhost:4318") => endpoint.replace(/\/+$/, "");
488
+ function createOtlpSender(opts = {}) {
489
+ const endpoint = normalizeEndpoint(opts.endpoint);
490
+ const doFetch = opts.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
491
+ const maxRetries = opts.maxRetries ?? 3;
492
+ const base = opts.retryBaseMs ?? 500;
493
+ const cap = opts.retryMaxMs ?? 3e4;
494
+ const fail = (signal, message) => {
495
+ try {
496
+ opts.onError?.(new Error(message), signal);
497
+ } catch {
498
+ }
499
+ };
500
+ return {
501
+ endpoint,
502
+ async send(signal, body, sendOpts) {
503
+ if (!doFetch) {
504
+ fail(signal, "otlp: no fetch available");
505
+ return false;
506
+ }
507
+ const text = JSON.stringify(body);
508
+ const keepalive = !!sendOpts?.keepalive && text.length < KEEPALIVE_MAX;
509
+ for (let attempt = 0; ; attempt++) {
510
+ let delay;
511
+ let reason;
512
+ try {
513
+ const res = await doFetch(`${endpoint}/v1/${signal}`, {
514
+ method: "POST",
515
+ headers: { ...opts.headers, "content-type": "application/json" },
516
+ body: text,
517
+ keepalive
518
+ });
519
+ if (res.ok) return true;
520
+ reason = `otlp ${signal}: HTTP ${res.status}`;
521
+ if (!RETRYABLE.has(res.status)) {
522
+ fail(signal, reason);
523
+ return false;
524
+ }
525
+ delay = retryAfterMs(res.headers.get("retry-after"));
526
+ } catch (err) {
527
+ reason = `otlp ${signal}: ${err instanceof Error ? err.message : String(err)}`;
528
+ }
529
+ if (attempt >= maxRetries) {
530
+ fail(signal, `${reason} (gave up after ${attempt + 1} attempts)`);
531
+ return false;
532
+ }
533
+ const backoff = base * 2 ** attempt;
534
+ await sleep(Math.min(cap, delay ?? backoff * (0.5 + Math.random() / 2)));
535
+ }
536
+ }
537
+ };
538
+ }
539
+ function createOtelPipeline(opts) {
540
+ const { sender, newMapper } = opts;
541
+ const maxBatch = Math.max(1, opts.maxBatch ?? 512);
542
+ const now = opts.now ?? Date.now;
543
+ const traces = opts.signals?.traces !== false;
544
+ const metrics = opts.signals?.metrics !== false;
545
+ const logs = opts.signals?.logs !== false;
546
+ const mappers = /* @__PURE__ */ new Map();
547
+ const released = /* @__PURE__ */ new Set();
548
+ let chain = Promise.resolve();
549
+ let flushing = false;
550
+ let closed = false;
551
+ const sendQueued = async (signal, keepalive) => {
552
+ for (; ; ) {
553
+ let budget = maxBatch;
554
+ const groups = [];
555
+ for (const m of mappers.values()) {
556
+ if (budget === 0) break;
557
+ const items = signal === "traces" ? m.takeSpans(budget) : m.takeLogs(budget);
558
+ if (items.length) {
559
+ groups.push({ resource: m.resource, items });
560
+ budget -= items.length;
561
+ }
562
+ }
563
+ if (groups.length === 0) return;
564
+ const body = signal === "traces" ? encodeTraces(groups.map((g) => ({ resource: g.resource, spans: g.items }))) : encodeLogs(groups.map((g) => ({ resource: g.resource, logs: g.items })));
565
+ await sender.send(signal, body, { keepalive });
566
+ }
567
+ };
568
+ const doFlush = async (keepalive) => {
569
+ flushing = true;
570
+ try {
571
+ if (traces) await sendQueued("traces", keepalive);
572
+ if (logs) await sendQueued("logs", keepalive);
573
+ if (metrics) {
574
+ const t = now();
575
+ const groups = [...mappers.values()].map((m) => ({ resource: m.resource, metrics: m.collectMetrics(t) })).filter((g) => g.metrics.length > 0);
576
+ if (groups.length) await sender.send("metrics", encodeMetrics(groups), { keepalive });
577
+ }
578
+ for (const key of released) {
579
+ const m = mappers.get(key);
580
+ if (!m || m.spans.length === 0 && m.logs.length === 0) {
581
+ mappers.delete(key);
582
+ released.delete(key);
583
+ }
584
+ }
585
+ } finally {
586
+ flushing = false;
587
+ }
588
+ };
589
+ const flush = (f) => {
590
+ chain = chain.then(() => doFlush(!!f?.keepalive)).catch(() => {
591
+ });
592
+ return chain;
593
+ };
594
+ const interval = opts.flushIntervalMs ?? 5e3;
595
+ const timer = interval > 0 ? setInterval(() => void flush(), interval) : void 0;
596
+ timer?.unref?.();
597
+ return {
598
+ ingest(ev, recvEpochMs, key = "") {
599
+ if (closed) return;
600
+ let m = mappers.get(key);
601
+ if (!m) {
602
+ m = newMapper(key);
603
+ mappers.set(key, m);
604
+ }
605
+ released.delete(key);
606
+ m.ingest(ev, recvEpochMs);
607
+ if (!flushing && (m.spans.length >= maxBatch || m.logs.length >= maxBatch)) void flush();
608
+ },
609
+ release(key) {
610
+ if (mappers.has(key)) released.add(key);
611
+ },
612
+ flush,
613
+ async close() {
614
+ if (closed) return chain;
615
+ closed = true;
616
+ if (timer) clearInterval(timer);
617
+ return flush();
618
+ }
619
+ };
620
+ }
621
+ function localResource(serviceName, extra = {}) {
622
+ const g = globalThis;
623
+ const browser = typeof g.window !== "undefined" && typeof g.window.document !== "undefined";
624
+ const node = !browser && g.process?.versions?.node !== void 0;
625
+ return toAttributes({
626
+ "service.name": serviceName ?? "unknown_service",
627
+ "telemetry.sdk.name": "atoll",
628
+ "telemetry.sdk.language": node ? "nodejs" : "webjs",
629
+ ...node ? {
630
+ "process.pid": g.process?.pid,
631
+ "process.runtime.name": "nodejs",
632
+ "process.runtime.version": g.process?.versions?.node
633
+ } : { "user_agent.original": g.navigator?.userAgent },
634
+ ...extra
635
+ });
636
+ }
637
+ function sessionResource(session, serviceName, extra = {}) {
638
+ const node = session.runtime === "node";
639
+ return toAttributes({
640
+ "service.name": serviceName ?? session.name ?? "unknown_service",
641
+ "service.instance.id": session.id,
642
+ "telemetry.sdk.name": "atoll",
643
+ "telemetry.sdk.language": node ? "nodejs" : "webjs",
644
+ ...node ? { "process.runtime.name": "nodejs", "process.runtime.version": session.env?.node?.replace(/^v/, "") } : { "user_agent.original": session.env?.userAgent },
645
+ "atoll.session.hint": session.hint,
646
+ "atoll.framework": session.framework,
647
+ ...extra
648
+ });
649
+ }
650
+
651
+ export {
652
+ SEVERITY,
653
+ toUnixNano,
654
+ newTraceId,
655
+ newSpanId,
656
+ OtelMapper,
657
+ createOtlpSender,
658
+ createOtelPipeline,
659
+ localResource,
660
+ sessionResource
661
+ };
package/dist/client.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface ConnectDevtoolsOptions {
13
13
  session?: {
14
14
  name?: string;
15
15
  hint?: string;
16
+ framework?: string;
16
17
  };
17
18
  /** Batch flush interval ms (default 100). */
18
19
  flushMs?: number;
@@ -24,6 +25,12 @@ export interface ConnectDevtoolsOptions {
24
25
  network?: boolean;
25
26
  /** Sample JS heap usage periodically — Chrome/Blink only (default true). */
26
27
  memory?: boolean;
28
+ /**
29
+ * Main-thread jank probe (default true): long frames (`runtime:longframe`,
30
+ * Long Animation Frames or `longtask`) and a rAF fps sampler
31
+ * (`runtime:frames`). Browser main thread only.
32
+ */
33
+ jank?: boolean;
27
34
  }
28
35
  export interface DevtoolsConnection {
29
36
  readonly open: boolean;
@@ -0,0 +1,4 @@
1
+ import type { ControlRequest, ControlResult, SessionEnv } from './protocol.js';
2
+ export declare const answerControl: (req: ControlRequest, reply: (res: ControlResult) => void) => Promise<void>;
3
+ /** Runtime facts for the session's `env` — what the audits panel reasons over. */
4
+ export declare const captureEnv: () => SessionEnv;
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  devtoolsEnabled,
5
5
  initDevtools,
6
6
  mountDevtoolsOverlay
7
- } from "./chunk-4N3NBFL3.js";
7
+ } from "./chunk-6E54FXKS.js";
8
+ import "./chunk-57NGHQUL.js";
8
9
  export {
9
10
  DEVTOOLS_PARAM,
10
11
  connectDevtools,