@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.
- package/README.md +408 -71
- package/app/api.js +68 -0
- package/app/fw-logos.js +14 -0
- package/app/index.html +161 -2
- package/app/main.js +897 -222
- 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-6E54FXKS.js +495 -0
- 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 +239 -47
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
- package/dist/chunk-4N3NBFL3.js +0 -474
package/dist/bin.js
CHANGED
|
@@ -134,6 +134,640 @@ Sec-WebSocket-Accept: ${acceptKey(String(key ?? randomBytes(16).toString("base64
|
|
|
134
134
|
return conn;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// src/otelMap.ts
|
|
138
|
+
var SPAN_KIND_CLIENT = 3;
|
|
139
|
+
var STATUS_ERROR = 2;
|
|
140
|
+
var TEMPORALITY_CUMULATIVE = 2;
|
|
141
|
+
var SEVERITY = {
|
|
142
|
+
trace: 1,
|
|
143
|
+
debug: 5,
|
|
144
|
+
info: 9,
|
|
145
|
+
warn: 13,
|
|
146
|
+
error: 17
|
|
147
|
+
};
|
|
148
|
+
var OTEL_SCOPE = { name: "@atolljs/devtools" };
|
|
149
|
+
var attr = {
|
|
150
|
+
str: (key, v) => ({ key, value: { stringValue: v } }),
|
|
151
|
+
int: (key, v) => ({ key, value: { intValue: String(Math.trunc(v)) } }),
|
|
152
|
+
dbl: (key, v) => ({ key, value: { doubleValue: v } }),
|
|
153
|
+
bool: (key, v) => ({ key, value: { boolValue: v } })
|
|
154
|
+
};
|
|
155
|
+
var toAttributes = (bag) => {
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const [k, v] of Object.entries(bag)) {
|
|
158
|
+
if (v === void 0) continue;
|
|
159
|
+
if (typeof v === "string") out.push(attr.str(k, v));
|
|
160
|
+
else if (typeof v === "boolean") out.push(attr.bool(k, v));
|
|
161
|
+
else if (Number.isSafeInteger(v)) out.push(attr.int(k, v));
|
|
162
|
+
else if (Number.isFinite(v)) out.push(attr.dbl(k, v));
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
var toUnixNano = (epochMs) => {
|
|
167
|
+
let whole = Math.floor(epochMs);
|
|
168
|
+
let frac = Math.round((epochMs - whole) * 1e6);
|
|
169
|
+
if (frac >= 1e6) {
|
|
170
|
+
whole += 1;
|
|
171
|
+
frac -= 1e6;
|
|
172
|
+
}
|
|
173
|
+
return (BigInt(whole) * 1000000n + BigInt(frac)).toString();
|
|
174
|
+
};
|
|
175
|
+
var hex = (bytes) => {
|
|
176
|
+
const a = new Uint8Array(bytes);
|
|
177
|
+
const c = globalThis.crypto;
|
|
178
|
+
if (c?.getRandomValues) c.getRandomValues(a);
|
|
179
|
+
else for (let i = 0; i < bytes; i++) a[i] = Math.random() * 256 | 0;
|
|
180
|
+
if (a.every((b) => b === 0)) a[bytes - 1] = 1;
|
|
181
|
+
let s = "";
|
|
182
|
+
for (const b of a) s += b.toString(16).padStart(2, "0");
|
|
183
|
+
return s;
|
|
184
|
+
};
|
|
185
|
+
var newTraceId = () => hex(16);
|
|
186
|
+
var newSpanId = () => hex(8);
|
|
187
|
+
var ClockRebaser = class {
|
|
188
|
+
constructor(originMs, resetMs = 1e3) {
|
|
189
|
+
this.originMs = originMs;
|
|
190
|
+
this.resetMs = resetMs;
|
|
191
|
+
}
|
|
192
|
+
originMs;
|
|
193
|
+
resetMs;
|
|
194
|
+
offsets = /* @__PURE__ */ new Map();
|
|
195
|
+
epochMs(ev, recvEpochMs) {
|
|
196
|
+
if (!ev.worker && this.originMs !== void 0) return this.originMs + ev.at;
|
|
197
|
+
const key = ev.worker ? `${ev.worker.poolId}#${ev.worker.slot}` : ev.thread;
|
|
198
|
+
const d = recvEpochMs - ev.at;
|
|
199
|
+
const cur = this.offsets.get(key);
|
|
200
|
+
const off = cur === void 0 || d < cur || d - cur > this.resetMs ? d : cur;
|
|
201
|
+
this.offsets.set(key, off);
|
|
202
|
+
return ev.at + off;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
var MS_BOUNDS = [0, 1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4];
|
|
206
|
+
var seriesKey = (attrs) => attrs.map((a) => `${a.key}=${JSON.stringify(a.value)}`).join("|");
|
|
207
|
+
var Instrument = class {
|
|
208
|
+
constructor(name, unit, description, kind, int, maxSeries, bounds = MS_BOUNDS) {
|
|
209
|
+
this.name = name;
|
|
210
|
+
this.unit = unit;
|
|
211
|
+
this.description = description;
|
|
212
|
+
this.kind = kind;
|
|
213
|
+
this.int = int;
|
|
214
|
+
this.maxSeries = maxSeries;
|
|
215
|
+
this.bounds = bounds;
|
|
216
|
+
}
|
|
217
|
+
name;
|
|
218
|
+
unit;
|
|
219
|
+
description;
|
|
220
|
+
kind;
|
|
221
|
+
int;
|
|
222
|
+
maxSeries;
|
|
223
|
+
bounds;
|
|
224
|
+
series = /* @__PURE__ */ new Map();
|
|
225
|
+
at(attrs, nowNs) {
|
|
226
|
+
const k = seriesKey(attrs);
|
|
227
|
+
let s = this.series.get(k);
|
|
228
|
+
if (!s) {
|
|
229
|
+
if (this.series.size >= this.maxSeries) return void 0;
|
|
230
|
+
s = {
|
|
231
|
+
attrs,
|
|
232
|
+
startNs: nowNs,
|
|
233
|
+
timeNs: nowNs,
|
|
234
|
+
value: 0,
|
|
235
|
+
count: 0,
|
|
236
|
+
sum: 0,
|
|
237
|
+
min: Infinity,
|
|
238
|
+
max: -Infinity,
|
|
239
|
+
buckets: new Array(this.bounds.length + 1).fill(0)
|
|
240
|
+
};
|
|
241
|
+
this.series.set(k, s);
|
|
242
|
+
}
|
|
243
|
+
return s;
|
|
244
|
+
}
|
|
245
|
+
add(attrs, v, nowNs) {
|
|
246
|
+
const s = this.at(attrs, nowNs);
|
|
247
|
+
if (s) s.value += v;
|
|
248
|
+
}
|
|
249
|
+
set(attrs, v, nowNs) {
|
|
250
|
+
const s = this.at(attrs, nowNs);
|
|
251
|
+
if (s) {
|
|
252
|
+
s.value = v;
|
|
253
|
+
s.timeNs = nowNs;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
record(attrs, v, nowNs) {
|
|
257
|
+
const s = this.at(attrs, nowNs);
|
|
258
|
+
if (!s) return;
|
|
259
|
+
s.count++;
|
|
260
|
+
s.sum += v;
|
|
261
|
+
if (v < s.min) s.min = v;
|
|
262
|
+
if (v > s.max) s.max = v;
|
|
263
|
+
let i = 0;
|
|
264
|
+
while (i < this.bounds.length && v > this.bounds[i]) i++;
|
|
265
|
+
s.buckets[i]++;
|
|
266
|
+
}
|
|
267
|
+
delete(attrs) {
|
|
268
|
+
this.series.delete(seriesKey(attrs));
|
|
269
|
+
}
|
|
270
|
+
encode(nowNs) {
|
|
271
|
+
if (this.series.size === 0) return null;
|
|
272
|
+
const base = { name: this.name, unit: this.unit, description: this.description };
|
|
273
|
+
const num = (s) => this.int ? { asInt: String(Math.trunc(s.value)) } : { asDouble: s.value };
|
|
274
|
+
const all = [...this.series.values()];
|
|
275
|
+
if (this.kind === "counter") {
|
|
276
|
+
return {
|
|
277
|
+
...base,
|
|
278
|
+
sum: {
|
|
279
|
+
aggregationTemporality: TEMPORALITY_CUMULATIVE,
|
|
280
|
+
isMonotonic: true,
|
|
281
|
+
dataPoints: all.map((s) => ({ attributes: s.attrs, startTimeUnixNano: s.startNs, timeUnixNano: nowNs, ...num(s) }))
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (this.kind === "gauge") {
|
|
286
|
+
return {
|
|
287
|
+
...base,
|
|
288
|
+
gauge: { dataPoints: all.map((s) => ({ attributes: s.attrs, timeUnixNano: s.timeNs, ...num(s) })) }
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const points = all.filter((s) => s.count > 0);
|
|
292
|
+
if (points.length === 0) return null;
|
|
293
|
+
return {
|
|
294
|
+
...base,
|
|
295
|
+
histogram: {
|
|
296
|
+
aggregationTemporality: TEMPORALITY_CUMULATIVE,
|
|
297
|
+
dataPoints: points.map((s) => ({
|
|
298
|
+
attributes: s.attrs,
|
|
299
|
+
startTimeUnixNano: s.startNs,
|
|
300
|
+
timeUnixNano: nowNs,
|
|
301
|
+
count: String(s.count),
|
|
302
|
+
sum: s.sum,
|
|
303
|
+
min: s.min,
|
|
304
|
+
max: s.max,
|
|
305
|
+
bucketCounts: s.buckets.map(String),
|
|
306
|
+
explicitBounds: this.bounds
|
|
307
|
+
}))
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
var ERROR_OUTCOMES = /* @__PURE__ */ new Set(["error", "timeout", "queue-full", "crashed"]);
|
|
313
|
+
var KNOWN_METHODS = /* @__PURE__ */ new Set(["CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", "QUERY"]);
|
|
314
|
+
var redactUrl = (url) => url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/@]*@/i, "$1REDACTED:REDACTED@");
|
|
315
|
+
var workerKey = (w) => w ? `${w.poolId}#${w.slot}` : void 0;
|
|
316
|
+
var OtelMapper = class {
|
|
317
|
+
resource;
|
|
318
|
+
spans = [];
|
|
319
|
+
logs = [];
|
|
320
|
+
/** Items dropped by the queue bound since creation. */
|
|
321
|
+
dropped = 0;
|
|
322
|
+
clock;
|
|
323
|
+
traces;
|
|
324
|
+
metricsOn;
|
|
325
|
+
logsOn;
|
|
326
|
+
maxQueue;
|
|
327
|
+
maxPending;
|
|
328
|
+
ignoreUrl;
|
|
329
|
+
pending = /* @__PURE__ */ new Map();
|
|
330
|
+
pools = /* @__PURE__ */ new Set();
|
|
331
|
+
instruments = [];
|
|
332
|
+
m;
|
|
333
|
+
constructor(opts) {
|
|
334
|
+
this.resource = opts.resource;
|
|
335
|
+
this.clock = new ClockRebaser(opts.originMs);
|
|
336
|
+
this.traces = opts.signals?.traces !== false;
|
|
337
|
+
this.metricsOn = opts.signals?.metrics !== false;
|
|
338
|
+
this.logsOn = opts.signals?.logs !== false;
|
|
339
|
+
this.maxQueue = opts.maxQueue ?? 2048;
|
|
340
|
+
this.maxPending = opts.maxPending ?? 1e4;
|
|
341
|
+
this.ignoreUrl = opts.ignoreUrl;
|
|
342
|
+
const cap = opts.maxSeries ?? 2e3;
|
|
343
|
+
const mk = (name, unit, description, kind, int) => {
|
|
344
|
+
const i = new Instrument(name, unit, description, kind, int, cap);
|
|
345
|
+
this.instruments.push(i);
|
|
346
|
+
return i;
|
|
347
|
+
};
|
|
348
|
+
this.m = {
|
|
349
|
+
settled: mk("atoll.task.settled", "{call}", "Task calls settled, by outcome", "counter", true),
|
|
350
|
+
duration: mk("atoll.task.duration", "ms", "Task run time, dispatch to reply", "histogram", false),
|
|
351
|
+
wait: mk("atoll.task.queue_wait", "ms", "Task queue wait, enqueue to dispatch", "histogram", false),
|
|
352
|
+
inFlight: mk("atoll.task.in_flight", "{call}", "Task calls enqueued and not yet settled", "gauge", true),
|
|
353
|
+
islandOps: mk("atoll.island.ops", "{op}", "Island DOM ops replayed on the main thread", "counter", true),
|
|
354
|
+
heap: mk("atoll.runtime.heap", "By", "JS heap in use, per thread", "gauge", true),
|
|
355
|
+
rss: mk("atoll.runtime.rss", "By", "Resident set size, per thread (Node)", "gauge", true),
|
|
356
|
+
longFrames: mk("atoll.runtime.long_frames", "{frame}", "Main-thread frames over the 50ms budget", "counter", true),
|
|
357
|
+
blocking: mk("atoll.runtime.blocking", "ms", "Main-thread time past the 50ms frame budget", "counter", false),
|
|
358
|
+
fps: mk("atoll.runtime.fps", "{frame}/s", "Main-thread frame rate", "gauge", false),
|
|
359
|
+
writes: mk("atoll.memory.writes", "{write}", "Shared-memory field writes", "counter", true),
|
|
360
|
+
workerErrors: mk("atoll.worker.errors", "{error}", "Worker error events", "counter", true),
|
|
361
|
+
respawns: mk("atoll.worker.respawns", "{respawn}", "Crashed workers respawned", "counter", true)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
pushSpan(s) {
|
|
365
|
+
this.spans.push(s);
|
|
366
|
+
if (this.spans.length > this.maxQueue) {
|
|
367
|
+
this.spans.shift();
|
|
368
|
+
this.dropped++;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
pushLog(r) {
|
|
372
|
+
this.logs.push(r);
|
|
373
|
+
if (this.logs.length > this.maxQueue) {
|
|
374
|
+
this.logs.shift();
|
|
375
|
+
this.dropped++;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
log(level, body, attrs, tMs, recvMs) {
|
|
379
|
+
if (!this.logsOn) return;
|
|
380
|
+
this.pushLog({
|
|
381
|
+
timeUnixNano: toUnixNano(tMs),
|
|
382
|
+
observedTimeUnixNano: toUnixNano(recvMs),
|
|
383
|
+
severityNumber: SEVERITY[level],
|
|
384
|
+
severityText: level.toUpperCase(),
|
|
385
|
+
body: { stringValue: body },
|
|
386
|
+
attributes: attrs
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/** Thread attribution shared by logs and runtime metrics. */
|
|
390
|
+
threadAttrs(ev) {
|
|
391
|
+
const w = workerKey(ev.worker);
|
|
392
|
+
return w ? [attr.str("atoll.thread", "worker"), attr.str("atoll.worker", w)] : [attr.str("atoll.thread", ev.thread)];
|
|
393
|
+
}
|
|
394
|
+
/** Feed one event; `recvEpochMs` is when the sink saw it (rebases foreign clocks). */
|
|
395
|
+
ingest(ev, recvEpochMs) {
|
|
396
|
+
const t = this.clock.epochMs(ev, recvEpochMs);
|
|
397
|
+
const ns = () => toUnixNano(t);
|
|
398
|
+
switch (ev.type) {
|
|
399
|
+
case "pool:init":
|
|
400
|
+
this.pools.add(ev.poolId);
|
|
401
|
+
return;
|
|
402
|
+
case "pool:terminate":
|
|
403
|
+
this.pools.delete(ev.poolId);
|
|
404
|
+
if (this.metricsOn) this.m.inFlight.delete([attr.str("atoll.pool.id", ev.poolId)]);
|
|
405
|
+
return;
|
|
406
|
+
case "task:enqueue": {
|
|
407
|
+
if (!this.traces && !this.metricsOn) return;
|
|
408
|
+
this.pools.add(ev.poolId);
|
|
409
|
+
this.pending.set(`${ev.poolId}:${ev.callId}`, { poolId: ev.poolId, taskId: ev.taskId, startMs: t });
|
|
410
|
+
if (this.pending.size > this.maxPending) this.pending.delete(this.pending.keys().next().value);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
case "task:dispatch": {
|
|
414
|
+
const p = this.pending.get(`${ev.poolId}:${ev.callId}`);
|
|
415
|
+
if (p) {
|
|
416
|
+
p.slot = ev.slot;
|
|
417
|
+
p.waitMs = ev.waitMs;
|
|
418
|
+
p.argBytes = ev.argBytes;
|
|
419
|
+
p.dispatchMs = t;
|
|
420
|
+
}
|
|
421
|
+
if (this.metricsOn) {
|
|
422
|
+
this.m.wait.record([attr.str("atoll.pool.id", ev.poolId), attr.str("atoll.task.id", ev.taskId)], ev.waitMs, ns());
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
case "task:settle": {
|
|
427
|
+
const key = `${ev.poolId}:${ev.callId}`;
|
|
428
|
+
const p = this.pending.get(key);
|
|
429
|
+
this.pending.delete(key);
|
|
430
|
+
if (this.metricsOn) {
|
|
431
|
+
const base = [attr.str("atoll.pool.id", ev.poolId), attr.str("atoll.task.id", ev.taskId)];
|
|
432
|
+
this.m.settled.add([...base, attr.str("atoll.outcome", ev.outcome)], 1, ns());
|
|
433
|
+
if (ev.runMs !== void 0) this.m.duration.record(base, ev.runMs, ns());
|
|
434
|
+
}
|
|
435
|
+
if (!this.traces) return;
|
|
436
|
+
const attributes = [
|
|
437
|
+
attr.str("atoll.pool.id", ev.poolId),
|
|
438
|
+
attr.str("atoll.task.id", ev.taskId),
|
|
439
|
+
attr.str("atoll.outcome", ev.outcome)
|
|
440
|
+
];
|
|
441
|
+
if (p?.slot !== void 0) attributes.push(attr.int("atoll.worker.slot", p.slot));
|
|
442
|
+
if (p?.waitMs !== void 0) attributes.push(attr.dbl("atoll.queue_wait_ms", p.waitMs));
|
|
443
|
+
if (ev.runMs !== void 0) attributes.push(attr.dbl("atoll.run_ms", ev.runMs));
|
|
444
|
+
if (p?.argBytes !== void 0) attributes.push(attr.int("atoll.arg_bytes", p.argBytes));
|
|
445
|
+
if (ev.resultBytes !== void 0) attributes.push(attr.int("atoll.result_bytes", ev.resultBytes));
|
|
446
|
+
const fwd = workerKey(ev.worker);
|
|
447
|
+
if (fwd) attributes.push(attr.str("atoll.worker", fwd));
|
|
448
|
+
const span = {
|
|
449
|
+
traceId: newTraceId(),
|
|
450
|
+
spanId: newSpanId(),
|
|
451
|
+
name: `atoll.task ${ev.taskId}`,
|
|
452
|
+
kind: SPAN_KIND_CLIENT,
|
|
453
|
+
startTimeUnixNano: toUnixNano(p?.startMs ?? t - (ev.runMs ?? 0)),
|
|
454
|
+
endTimeUnixNano: ns(),
|
|
455
|
+
attributes
|
|
456
|
+
};
|
|
457
|
+
if (p?.dispatchMs !== void 0) {
|
|
458
|
+
span.events = [{
|
|
459
|
+
timeUnixNano: toUnixNano(p.dispatchMs),
|
|
460
|
+
name: "atoll.dispatch",
|
|
461
|
+
attributes: [attr.int("atoll.worker.slot", p.slot ?? -1), attr.dbl("atoll.queue_wait_ms", p.waitMs ?? 0)]
|
|
462
|
+
}];
|
|
463
|
+
}
|
|
464
|
+
if (ERROR_OUTCOMES.has(ev.outcome)) span.status = { code: STATUS_ERROR, message: ev.error ?? ev.outcome };
|
|
465
|
+
this.pushSpan(span);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
case "island:task": {
|
|
469
|
+
if (!this.traces) return;
|
|
470
|
+
const attributes = [attr.str("atoll.island.instance", ev.instance), attr.str("atoll.island.method", ev.method)];
|
|
471
|
+
if (ev.ops !== void 0) attributes.push(attr.int("atoll.island.ops", ev.ops));
|
|
472
|
+
const span = {
|
|
473
|
+
traceId: newTraceId(),
|
|
474
|
+
spanId: newSpanId(),
|
|
475
|
+
name: `atoll.island ${ev.instance} ${ev.method}`,
|
|
476
|
+
kind: SPAN_KIND_CLIENT,
|
|
477
|
+
startTimeUnixNano: toUnixNano(t - ev.ms),
|
|
478
|
+
endTimeUnixNano: ns(),
|
|
479
|
+
attributes
|
|
480
|
+
};
|
|
481
|
+
if (ev.error !== void 0) span.status = { code: STATUS_ERROR, message: ev.error };
|
|
482
|
+
this.pushSpan(span);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
case "island:ops":
|
|
486
|
+
if (this.metricsOn) {
|
|
487
|
+
this.m.islandOps.add(
|
|
488
|
+
[attr.str("atoll.island.instance", ev.instance), attr.str("atoll.island.via", ev.via)],
|
|
489
|
+
ev.count,
|
|
490
|
+
ns()
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
case "net:fetch": {
|
|
495
|
+
if (!this.traces || this.ignoreUrl?.(ev.url)) return;
|
|
496
|
+
const known = KNOWN_METHODS.has(ev.method);
|
|
497
|
+
const attributes = [attr.str("http.request.method", known ? ev.method : "_OTHER")];
|
|
498
|
+
if (!known) attributes.push(attr.str("http.request.method_original", ev.method));
|
|
499
|
+
attributes.push(attr.str("url.full", redactUrl(ev.url)));
|
|
500
|
+
try {
|
|
501
|
+
const u = new URL(ev.url);
|
|
502
|
+
attributes.push(attr.str("server.address", u.hostname));
|
|
503
|
+
if (u.port) attributes.push(attr.int("server.port", Number(u.port)));
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
if (ev.status !== void 0) attributes.push(attr.int("http.response.status_code", ev.status));
|
|
507
|
+
if (ev.error !== void 0) attributes.push(attr.str("error.type", "_OTHER"));
|
|
508
|
+
else if (ev.status !== void 0 && ev.status >= 400) attributes.push(attr.str("error.type", String(ev.status)));
|
|
509
|
+
const fwd = workerKey(ev.worker);
|
|
510
|
+
if (fwd) attributes.push(attr.str("atoll.worker", fwd));
|
|
511
|
+
const span = {
|
|
512
|
+
traceId: newTraceId(),
|
|
513
|
+
spanId: newSpanId(),
|
|
514
|
+
name: known ? ev.method : "HTTP",
|
|
515
|
+
kind: SPAN_KIND_CLIENT,
|
|
516
|
+
startTimeUnixNano: toUnixNano(t - ev.ms),
|
|
517
|
+
endTimeUnixNano: ns(),
|
|
518
|
+
attributes
|
|
519
|
+
};
|
|
520
|
+
if (ev.error !== void 0) span.status = { code: STATUS_ERROR, message: ev.error };
|
|
521
|
+
else if (ev.status !== void 0 && ev.status >= 400) span.status = { code: STATUS_ERROR };
|
|
522
|
+
this.pushSpan(span);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
case "runtime:memory":
|
|
526
|
+
if (this.metricsOn) {
|
|
527
|
+
const a = this.threadAttrs(ev);
|
|
528
|
+
this.m.heap.set(a, ev.heapBytes, ns());
|
|
529
|
+
if (ev.rssBytes !== void 0) this.m.rss.set(a, ev.rssBytes, ns());
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
case "runtime:longframe":
|
|
533
|
+
if (this.metricsOn) {
|
|
534
|
+
this.m.longFrames.add([], 1, ns());
|
|
535
|
+
this.m.blocking.add([], ev.blockingMs, ns());
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
case "runtime:frames":
|
|
539
|
+
if (this.metricsOn) this.m.fps.set([], ev.fps, ns());
|
|
540
|
+
return;
|
|
541
|
+
case "memory:write":
|
|
542
|
+
if (this.metricsOn) this.m.writes.add([attr.str("atoll.memory.field", ev.path)], 1, ns());
|
|
543
|
+
return;
|
|
544
|
+
case "worker:error": {
|
|
545
|
+
const a = [attr.str("atoll.pool.id", ev.poolId), attr.int("atoll.worker.slot", ev.slot)];
|
|
546
|
+
if (this.metricsOn) this.m.workerErrors.add([a[0]], 1, ns());
|
|
547
|
+
this.log("error", `worker error: ${ev.message}`, [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
case "worker:respawn": {
|
|
551
|
+
const a = [attr.str("atoll.pool.id", ev.poolId), attr.int("atoll.worker.slot", ev.slot)];
|
|
552
|
+
if (this.metricsOn) this.m.respawns.add([a[0]], 1, ns());
|
|
553
|
+
this.log("info", "worker respawned", [...a, ...this.threadAttrs(ev)], t, recvEpochMs);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
case "log": {
|
|
557
|
+
const a = [attr.str("atoll.log.scope", ev.scope), ...this.threadAttrs(ev)];
|
|
558
|
+
if (ev.data !== void 0) a.push(attr.str("atoll.log.data", ev.data));
|
|
559
|
+
this.log(ev.level, ev.message, a, t, recvEpochMs);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
default:
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Remove up to `max` queued spans. */
|
|
567
|
+
takeSpans(max) {
|
|
568
|
+
return this.spans.splice(0, max);
|
|
569
|
+
}
|
|
570
|
+
/** Remove up to `max` queued log records. */
|
|
571
|
+
takeLogs(max) {
|
|
572
|
+
return this.logs.splice(0, max);
|
|
573
|
+
}
|
|
574
|
+
/** Cumulative snapshot of every metric with data; empty when metrics are off. */
|
|
575
|
+
collectMetrics(nowEpochMs) {
|
|
576
|
+
if (!this.metricsOn) return [];
|
|
577
|
+
const nowNs = toUnixNano(nowEpochMs);
|
|
578
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
579
|
+
for (const id of this.pools) inFlight.set(id, 0);
|
|
580
|
+
for (const p of this.pending.values()) inFlight.set(p.poolId, (inFlight.get(p.poolId) ?? 0) + 1);
|
|
581
|
+
for (const [id, n] of inFlight) this.m.inFlight.set([attr.str("atoll.pool.id", id)], n, nowNs);
|
|
582
|
+
const out = [];
|
|
583
|
+
for (const i of this.instruments) {
|
|
584
|
+
const m = i.encode(nowNs);
|
|
585
|
+
if (m) out.push(m);
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
var encodeTraces = (groups) => ({
|
|
591
|
+
resourceSpans: groups.map((g) => ({
|
|
592
|
+
resource: { attributes: g.resource },
|
|
593
|
+
scopeSpans: [{ scope: OTEL_SCOPE, spans: g.spans }]
|
|
594
|
+
}))
|
|
595
|
+
});
|
|
596
|
+
var encodeMetrics = (groups) => ({
|
|
597
|
+
resourceMetrics: groups.map((g) => ({
|
|
598
|
+
resource: { attributes: g.resource },
|
|
599
|
+
scopeMetrics: [{ scope: OTEL_SCOPE, metrics: g.metrics }]
|
|
600
|
+
}))
|
|
601
|
+
});
|
|
602
|
+
var encodeLogs = (groups) => ({
|
|
603
|
+
resourceLogs: groups.map((g) => ({
|
|
604
|
+
resource: { attributes: g.resource },
|
|
605
|
+
scopeLogs: [{ scope: OTEL_SCOPE, logRecords: g.logs }]
|
|
606
|
+
}))
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// src/otelTransport.ts
|
|
610
|
+
var RETRYABLE = /* @__PURE__ */ new Set([429, 502, 503, 504]);
|
|
611
|
+
var KEEPALIVE_MAX = 6e4;
|
|
612
|
+
var sleep = (ms) => new Promise((r) => {
|
|
613
|
+
const t = setTimeout(r, ms);
|
|
614
|
+
t.unref?.();
|
|
615
|
+
});
|
|
616
|
+
var retryAfterMs = (h) => {
|
|
617
|
+
if (!h) return void 0;
|
|
618
|
+
const secs = Number(h);
|
|
619
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
620
|
+
const at = Date.parse(h);
|
|
621
|
+
return Number.isNaN(at) ? void 0 : Math.max(0, at - Date.now());
|
|
622
|
+
};
|
|
623
|
+
var normalizeEndpoint = (endpoint = "http://localhost:4318") => endpoint.replace(/\/+$/, "");
|
|
624
|
+
function createOtlpSender(opts = {}) {
|
|
625
|
+
const endpoint = normalizeEndpoint(opts.endpoint);
|
|
626
|
+
const doFetch = opts.fetch ?? (typeof fetch === "function" ? fetch.bind(globalThis) : void 0);
|
|
627
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
628
|
+
const base = opts.retryBaseMs ?? 500;
|
|
629
|
+
const cap = opts.retryMaxMs ?? 3e4;
|
|
630
|
+
const fail = (signal, message) => {
|
|
631
|
+
try {
|
|
632
|
+
opts.onError?.(new Error(message), signal);
|
|
633
|
+
} catch {
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
return {
|
|
637
|
+
endpoint,
|
|
638
|
+
async send(signal, body, sendOpts) {
|
|
639
|
+
if (!doFetch) {
|
|
640
|
+
fail(signal, "otlp: no fetch available");
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
const text = JSON.stringify(body);
|
|
644
|
+
const keepalive = !!sendOpts?.keepalive && text.length < KEEPALIVE_MAX;
|
|
645
|
+
for (let attempt = 0; ; attempt++) {
|
|
646
|
+
let delay;
|
|
647
|
+
let reason;
|
|
648
|
+
try {
|
|
649
|
+
const res = await doFetch(`${endpoint}/v1/${signal}`, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: { ...opts.headers, "content-type": "application/json" },
|
|
652
|
+
body: text,
|
|
653
|
+
keepalive
|
|
654
|
+
});
|
|
655
|
+
if (res.ok) return true;
|
|
656
|
+
reason = `otlp ${signal}: HTTP ${res.status}`;
|
|
657
|
+
if (!RETRYABLE.has(res.status)) {
|
|
658
|
+
fail(signal, reason);
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
delay = retryAfterMs(res.headers.get("retry-after"));
|
|
662
|
+
} catch (err) {
|
|
663
|
+
reason = `otlp ${signal}: ${err instanceof Error ? err.message : String(err)}`;
|
|
664
|
+
}
|
|
665
|
+
if (attempt >= maxRetries) {
|
|
666
|
+
fail(signal, `${reason} (gave up after ${attempt + 1} attempts)`);
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
const backoff = base * 2 ** attempt;
|
|
670
|
+
await sleep(Math.min(cap, delay ?? backoff * (0.5 + Math.random() / 2)));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function createOtelPipeline(opts) {
|
|
676
|
+
const { sender, newMapper } = opts;
|
|
677
|
+
const maxBatch = Math.max(1, opts.maxBatch ?? 512);
|
|
678
|
+
const now = opts.now ?? Date.now;
|
|
679
|
+
const traces = opts.signals?.traces !== false;
|
|
680
|
+
const metrics = opts.signals?.metrics !== false;
|
|
681
|
+
const logs = opts.signals?.logs !== false;
|
|
682
|
+
const mappers = /* @__PURE__ */ new Map();
|
|
683
|
+
const released = /* @__PURE__ */ new Set();
|
|
684
|
+
let chain = Promise.resolve();
|
|
685
|
+
let flushing = false;
|
|
686
|
+
let closed = false;
|
|
687
|
+
const sendQueued = async (signal, keepalive) => {
|
|
688
|
+
for (; ; ) {
|
|
689
|
+
let budget = maxBatch;
|
|
690
|
+
const groups = [];
|
|
691
|
+
for (const m of mappers.values()) {
|
|
692
|
+
if (budget === 0) break;
|
|
693
|
+
const items = signal === "traces" ? m.takeSpans(budget) : m.takeLogs(budget);
|
|
694
|
+
if (items.length) {
|
|
695
|
+
groups.push({ resource: m.resource, items });
|
|
696
|
+
budget -= items.length;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (groups.length === 0) return;
|
|
700
|
+
const body = signal === "traces" ? encodeTraces(groups.map((g) => ({ resource: g.resource, spans: g.items }))) : encodeLogs(groups.map((g) => ({ resource: g.resource, logs: g.items })));
|
|
701
|
+
await sender.send(signal, body, { keepalive });
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const doFlush = async (keepalive) => {
|
|
705
|
+
flushing = true;
|
|
706
|
+
try {
|
|
707
|
+
if (traces) await sendQueued("traces", keepalive);
|
|
708
|
+
if (logs) await sendQueued("logs", keepalive);
|
|
709
|
+
if (metrics) {
|
|
710
|
+
const t = now();
|
|
711
|
+
const groups = [...mappers.values()].map((m) => ({ resource: m.resource, metrics: m.collectMetrics(t) })).filter((g) => g.metrics.length > 0);
|
|
712
|
+
if (groups.length) await sender.send("metrics", encodeMetrics(groups), { keepalive });
|
|
713
|
+
}
|
|
714
|
+
for (const key of released) {
|
|
715
|
+
const m = mappers.get(key);
|
|
716
|
+
if (!m || m.spans.length === 0 && m.logs.length === 0) {
|
|
717
|
+
mappers.delete(key);
|
|
718
|
+
released.delete(key);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
} finally {
|
|
722
|
+
flushing = false;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
const flush = (f) => {
|
|
726
|
+
chain = chain.then(() => doFlush(!!f?.keepalive)).catch(() => {
|
|
727
|
+
});
|
|
728
|
+
return chain;
|
|
729
|
+
};
|
|
730
|
+
const interval = opts.flushIntervalMs ?? 5e3;
|
|
731
|
+
const timer = interval > 0 ? setInterval(() => void flush(), interval) : void 0;
|
|
732
|
+
timer?.unref?.();
|
|
733
|
+
return {
|
|
734
|
+
ingest(ev, recvEpochMs, key = "") {
|
|
735
|
+
if (closed) return;
|
|
736
|
+
let m = mappers.get(key);
|
|
737
|
+
if (!m) {
|
|
738
|
+
m = newMapper(key);
|
|
739
|
+
mappers.set(key, m);
|
|
740
|
+
}
|
|
741
|
+
released.delete(key);
|
|
742
|
+
m.ingest(ev, recvEpochMs);
|
|
743
|
+
if (!flushing && (m.spans.length >= maxBatch || m.logs.length >= maxBatch)) void flush();
|
|
744
|
+
},
|
|
745
|
+
release(key) {
|
|
746
|
+
if (mappers.has(key)) released.add(key);
|
|
747
|
+
},
|
|
748
|
+
flush,
|
|
749
|
+
async close() {
|
|
750
|
+
if (closed) return chain;
|
|
751
|
+
closed = true;
|
|
752
|
+
if (timer) clearInterval(timer);
|
|
753
|
+
return flush();
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
function sessionResource(session, serviceName, extra = {}) {
|
|
758
|
+
const node = session.runtime === "node";
|
|
759
|
+
return toAttributes({
|
|
760
|
+
"service.name": serviceName ?? session.name ?? "unknown_service",
|
|
761
|
+
"service.instance.id": session.id,
|
|
762
|
+
"telemetry.sdk.name": "atoll",
|
|
763
|
+
"telemetry.sdk.language": node ? "nodejs" : "webjs",
|
|
764
|
+
...node ? { "process.runtime.name": "nodejs", "process.runtime.version": session.env?.node?.replace(/^v/, "") } : { "user_agent.original": session.env?.userAgent },
|
|
765
|
+
"atoll.session.hint": session.hint,
|
|
766
|
+
"atoll.framework": session.framework,
|
|
767
|
+
...extra
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
137
771
|
// src/server.ts
|
|
138
772
|
var MIME = {
|
|
139
773
|
".html": "text/html; charset=utf-8",
|
|
@@ -169,6 +803,22 @@ function createDevtoolsServer(opts = {}) {
|
|
|
169
803
|
const closedCap = 20;
|
|
170
804
|
const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1e3;
|
|
171
805
|
const tail = [];
|
|
806
|
+
const pendingControl = /* @__PURE__ */ new Map();
|
|
807
|
+
let otlp = null;
|
|
808
|
+
const otlpSessions = /* @__PURE__ */ new Map();
|
|
809
|
+
if (opts.otlp) {
|
|
810
|
+
const o = opts.otlp;
|
|
811
|
+
otlp = createOtelPipeline({
|
|
812
|
+
sender: createOtlpSender(o),
|
|
813
|
+
signals: o.signals,
|
|
814
|
+
flushIntervalMs: o.flushIntervalMs,
|
|
815
|
+
maxBatch: o.maxBatch,
|
|
816
|
+
newMapper: (id) => new OtelMapper({
|
|
817
|
+
resource: sessionResource(otlpSessions.get(id) ?? { id, runtime: "browser" }, o.serviceName, o.resource),
|
|
818
|
+
signals: o.signals
|
|
819
|
+
})
|
|
820
|
+
});
|
|
821
|
+
}
|
|
172
822
|
const sessionList = () => [...sessions.values(), ...closedSessions];
|
|
173
823
|
const evictClosed = (sessionId) => {
|
|
174
824
|
const i = closedSessions.findIndex((s) => s.id === sessionId);
|
|
@@ -206,7 +856,10 @@ function createDevtoolsServer(opts = {}) {
|
|
|
206
856
|
if (head?.length) socket.unshift(head);
|
|
207
857
|
if (url.pathname === "/view") {
|
|
208
858
|
viewers.add(conn);
|
|
209
|
-
conn.onClose = () =>
|
|
859
|
+
conn.onClose = () => {
|
|
860
|
+
viewers.delete(conn);
|
|
861
|
+
for (const [id, v] of pendingControl) if (v === conn) pendingControl.delete(id);
|
|
862
|
+
};
|
|
210
863
|
conn.onMessage = (text) => {
|
|
211
864
|
let msg;
|
|
212
865
|
try {
|
|
@@ -223,6 +876,20 @@ function createDevtoolsServer(opts = {}) {
|
|
|
223
876
|
if (!s || s.pinned === msg.pinned) return;
|
|
224
877
|
s.pinned = msg.pinned;
|
|
225
878
|
broadcast({ type: "sessions", sessions: sessionList() });
|
|
879
|
+
} else if (msg.type === "control") {
|
|
880
|
+
const app = [...sessions].find(([, s]) => s.id === msg.sessionId)?.[0];
|
|
881
|
+
if (!app) {
|
|
882
|
+
conn.send(JSON.stringify({
|
|
883
|
+
type: "control-result",
|
|
884
|
+
sessionId: msg.sessionId,
|
|
885
|
+
id: msg.id,
|
|
886
|
+
ok: false,
|
|
887
|
+
error: "session is not live"
|
|
888
|
+
}));
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
pendingControl.set(msg.id, conn);
|
|
892
|
+
app.send(JSON.stringify(msg));
|
|
226
893
|
}
|
|
227
894
|
};
|
|
228
895
|
conn.send(JSON.stringify({ type: "sessions", sessions: sessionList() }));
|
|
@@ -244,11 +911,26 @@ function createDevtoolsServer(opts = {}) {
|
|
|
244
911
|
return;
|
|
245
912
|
}
|
|
246
913
|
if (msg.type === "batch" && greeted) {
|
|
247
|
-
|
|
914
|
+
const session = sessions.get(conn);
|
|
915
|
+
broadcast({ type: "batch", session, events: msg.events });
|
|
916
|
+
if (otlp && Array.isArray(msg.events)) {
|
|
917
|
+
otlpSessions.set(session.id, session);
|
|
918
|
+
const recv = Date.now();
|
|
919
|
+
for (const ev of msg.events) otlp.ingest(ev, recv, session.id);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (msg.type === "control-result") {
|
|
923
|
+
const viewer = pendingControl.get(msg.id);
|
|
924
|
+
pendingControl.delete(msg.id);
|
|
925
|
+
if (viewer && viewers.has(viewer)) viewer.send(JSON.stringify(msg));
|
|
248
926
|
}
|
|
249
927
|
};
|
|
250
928
|
conn.onClose = () => {
|
|
251
929
|
const info = sessions.get(conn);
|
|
930
|
+
if (info && otlp) {
|
|
931
|
+
otlp.release(info.id);
|
|
932
|
+
otlpSessions.delete(info.id);
|
|
933
|
+
}
|
|
252
934
|
if (sessions.delete(conn) && info) {
|
|
253
935
|
info.closed = true;
|
|
254
936
|
info.closedAt = Date.now();
|
|
@@ -287,7 +969,10 @@ function createDevtoolsServer(opts = {}) {
|
|
|
287
969
|
clearInterval(sweep);
|
|
288
970
|
for (const v of viewers) v.close();
|
|
289
971
|
for (const c of sessions.keys()) c.close();
|
|
290
|
-
server2.close(() =>
|
|
972
|
+
server2.close(() => {
|
|
973
|
+
if (otlp) void otlp.close().then(() => done());
|
|
974
|
+
else done();
|
|
975
|
+
});
|
|
291
976
|
})
|
|
292
977
|
});
|
|
293
978
|
});
|
|
@@ -299,15 +984,29 @@ var arg = (name) => {
|
|
|
299
984
|
const i = process.argv.indexOf(`--${name}`);
|
|
300
985
|
return i >= 0 ? process.argv[i + 1] : void 0;
|
|
301
986
|
};
|
|
987
|
+
var parseHeaders = (list = "") => Object.fromEntries(
|
|
988
|
+
list.split(",").filter((h) => h.includes("=")).map((h) => [h.slice(0, h.indexOf("=")).trim(), h.slice(h.indexOf("=") + 1).trim()]).filter(([k]) => k !== "")
|
|
989
|
+
);
|
|
302
990
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
303
|
-
console.log(
|
|
991
|
+
console.log(
|
|
992
|
+
"usage: atoll-devtools [--port N] [--otlp URL [--otlp-headers k=v,k2=v2] [--otlp-service NAME]]\n serve the devtools dashboard + event ingest (default port 4780);\n --otlp also forwards every session to an OTLP/HTTP endpoint (e.g. http://localhost:4318)"
|
|
993
|
+
);
|
|
304
994
|
process.exit(0);
|
|
305
995
|
}
|
|
306
996
|
var port = Number(arg("port") ?? 4780);
|
|
997
|
+
var otlpEndpoint = arg("otlp");
|
|
998
|
+
var headers = parseHeaders(arg("otlp-headers"));
|
|
307
999
|
var server = await createDevtoolsServer({
|
|
308
|
-
port: Number.isFinite(port) ? port : 4780
|
|
1000
|
+
port: Number.isFinite(port) ? port : 4780,
|
|
1001
|
+
otlp: otlpEndpoint ? {
|
|
1002
|
+
endpoint: otlpEndpoint,
|
|
1003
|
+
headers,
|
|
1004
|
+
serviceName: arg("otlp-service"),
|
|
1005
|
+
onError: (err) => console.warn(`otlp: ${err.message}`)
|
|
1006
|
+
} : void 0
|
|
309
1007
|
});
|
|
310
1008
|
console.log(`atoll devtools \u2192 ${server.url}`);
|
|
311
1009
|
console.log(" dashboard: " + server.url);
|
|
312
1010
|
console.log(" ingest: " + server.url.replace("http://", "ws://") + "/events");
|
|
1011
|
+
if (otlpEndpoint) console.log(" otlp: " + otlpEndpoint);
|
|
313
1012
|
console.log("\nIn the app: import { connectDevtools } from '@atolljs/devtools'; connectDevtools()");
|