@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/server.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OtelMapper,
|
|
3
|
+
createOtelPipeline,
|
|
4
|
+
createOtlpSender,
|
|
5
|
+
sessionResource
|
|
6
|
+
} from "./chunk-WIL2P7HG.js";
|
|
7
|
+
|
|
1
8
|
// src/server.ts
|
|
2
9
|
import { createServer } from "node:http";
|
|
3
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -167,6 +174,22 @@ function createDevtoolsServer(opts = {}) {
|
|
|
167
174
|
const closedCap = 20;
|
|
168
175
|
const closedTtlMs = opts.closedTtlMs ?? 30 * 60 * 1e3;
|
|
169
176
|
const tail = [];
|
|
177
|
+
const pendingControl = /* @__PURE__ */ new Map();
|
|
178
|
+
let otlp = null;
|
|
179
|
+
const otlpSessions = /* @__PURE__ */ new Map();
|
|
180
|
+
if (opts.otlp) {
|
|
181
|
+
const o = opts.otlp;
|
|
182
|
+
otlp = createOtelPipeline({
|
|
183
|
+
sender: createOtlpSender(o),
|
|
184
|
+
signals: o.signals,
|
|
185
|
+
flushIntervalMs: o.flushIntervalMs,
|
|
186
|
+
maxBatch: o.maxBatch,
|
|
187
|
+
newMapper: (id) => new OtelMapper({
|
|
188
|
+
resource: sessionResource(otlpSessions.get(id) ?? { id, runtime: "browser" }, o.serviceName, o.resource),
|
|
189
|
+
signals: o.signals
|
|
190
|
+
})
|
|
191
|
+
});
|
|
192
|
+
}
|
|
170
193
|
const sessionList = () => [...sessions.values(), ...closedSessions];
|
|
171
194
|
const evictClosed = (sessionId) => {
|
|
172
195
|
const i = closedSessions.findIndex((s) => s.id === sessionId);
|
|
@@ -204,7 +227,10 @@ function createDevtoolsServer(opts = {}) {
|
|
|
204
227
|
if (head?.length) socket.unshift(head);
|
|
205
228
|
if (url.pathname === "/view") {
|
|
206
229
|
viewers.add(conn);
|
|
207
|
-
conn.onClose = () =>
|
|
230
|
+
conn.onClose = () => {
|
|
231
|
+
viewers.delete(conn);
|
|
232
|
+
for (const [id, v] of pendingControl) if (v === conn) pendingControl.delete(id);
|
|
233
|
+
};
|
|
208
234
|
conn.onMessage = (text) => {
|
|
209
235
|
let msg;
|
|
210
236
|
try {
|
|
@@ -221,6 +247,20 @@ function createDevtoolsServer(opts = {}) {
|
|
|
221
247
|
if (!s || s.pinned === msg.pinned) return;
|
|
222
248
|
s.pinned = msg.pinned;
|
|
223
249
|
broadcast({ type: "sessions", sessions: sessionList() });
|
|
250
|
+
} else if (msg.type === "control") {
|
|
251
|
+
const app = [...sessions].find(([, s]) => s.id === msg.sessionId)?.[0];
|
|
252
|
+
if (!app) {
|
|
253
|
+
conn.send(JSON.stringify({
|
|
254
|
+
type: "control-result",
|
|
255
|
+
sessionId: msg.sessionId,
|
|
256
|
+
id: msg.id,
|
|
257
|
+
ok: false,
|
|
258
|
+
error: "session is not live"
|
|
259
|
+
}));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
pendingControl.set(msg.id, conn);
|
|
263
|
+
app.send(JSON.stringify(msg));
|
|
224
264
|
}
|
|
225
265
|
};
|
|
226
266
|
conn.send(JSON.stringify({ type: "sessions", sessions: sessionList() }));
|
|
@@ -242,11 +282,26 @@ function createDevtoolsServer(opts = {}) {
|
|
|
242
282
|
return;
|
|
243
283
|
}
|
|
244
284
|
if (msg.type === "batch" && greeted) {
|
|
245
|
-
|
|
285
|
+
const session = sessions.get(conn);
|
|
286
|
+
broadcast({ type: "batch", session, events: msg.events });
|
|
287
|
+
if (otlp && Array.isArray(msg.events)) {
|
|
288
|
+
otlpSessions.set(session.id, session);
|
|
289
|
+
const recv = Date.now();
|
|
290
|
+
for (const ev of msg.events) otlp.ingest(ev, recv, session.id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (msg.type === "control-result") {
|
|
294
|
+
const viewer = pendingControl.get(msg.id);
|
|
295
|
+
pendingControl.delete(msg.id);
|
|
296
|
+
if (viewer && viewers.has(viewer)) viewer.send(JSON.stringify(msg));
|
|
246
297
|
}
|
|
247
298
|
};
|
|
248
299
|
conn.onClose = () => {
|
|
249
300
|
const info = sessions.get(conn);
|
|
301
|
+
if (info && otlp) {
|
|
302
|
+
otlp.release(info.id);
|
|
303
|
+
otlpSessions.delete(info.id);
|
|
304
|
+
}
|
|
250
305
|
if (sessions.delete(conn) && info) {
|
|
251
306
|
info.closed = true;
|
|
252
307
|
info.closedAt = Date.now();
|
|
@@ -285,7 +340,10 @@ function createDevtoolsServer(opts = {}) {
|
|
|
285
340
|
clearInterval(sweep);
|
|
286
341
|
for (const v of viewers) v.close();
|
|
287
342
|
for (const c of sessions.keys()) c.close();
|
|
288
|
-
server.close(() =>
|
|
343
|
+
server.close(() => {
|
|
344
|
+
if (otlp) void otlp.close().then(() => done());
|
|
345
|
+
else done();
|
|
346
|
+
});
|
|
289
347
|
})
|
|
290
348
|
});
|
|
291
349
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atolljs/devtools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Devtools transport + local dashboard server for AtollJS instrumentation events",
|
|
5
5
|
"homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"types": "./dist/node.d.ts",
|
|
14
14
|
"default": "./dist/node.js"
|
|
15
15
|
},
|
|
16
|
+
"./otel": {
|
|
17
|
+
"types": "./dist/otel.d.ts",
|
|
18
|
+
"default": "./dist/otel.js"
|
|
19
|
+
},
|
|
16
20
|
"./server": {
|
|
17
21
|
"types": "./dist/server.d.ts",
|
|
18
22
|
"default": "./dist/server.js"
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
"registry": "https://registry.npmjs.org"
|
|
36
40
|
},
|
|
37
41
|
"peerDependencies": {
|
|
38
|
-
"@atolljs/core": "0.1.
|
|
42
|
+
"@atolljs/core": "0.1.9"
|
|
39
43
|
},
|
|
40
44
|
"repository": {
|
|
41
45
|
"type": "git",
|
package/src/bin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/** `atoll-devtools [--port N]` — boot the local devtools server + dashboard. */
|
|
2
|
+
/** `atoll-devtools [--port N] [--otlp URL]` — boot the local devtools server + dashboard. */
|
|
3
3
|
import { createDevtoolsServer } from './server';
|
|
4
4
|
|
|
5
5
|
const arg = (name: string): string | undefined => {
|
|
@@ -7,17 +7,42 @@ const arg = (name: string): string | undefined => {
|
|
|
7
7
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
+
/** `k=v,k2=v2` (the OTEL_EXPORTER_OTLP_HEADERS format) → a header record. */
|
|
11
|
+
const parseHeaders = (list = ''): Record<string, string> =>
|
|
12
|
+
Object.fromEntries(
|
|
13
|
+
list
|
|
14
|
+
.split(',')
|
|
15
|
+
.filter((h) => h.includes('='))
|
|
16
|
+
.map((h) => [h.slice(0, h.indexOf('=')).trim(), h.slice(h.indexOf('=') + 1).trim()])
|
|
17
|
+
.filter(([k]) => k !== ''),
|
|
18
|
+
);
|
|
19
|
+
|
|
10
20
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
11
|
-
console.log(
|
|
21
|
+
console.log(
|
|
22
|
+
'usage: atoll-devtools [--port N] [--otlp URL [--otlp-headers k=v,k2=v2] [--otlp-service NAME]]\n' +
|
|
23
|
+
' serve the devtools dashboard + event ingest (default port 4780);\n' +
|
|
24
|
+
' --otlp also forwards every session to an OTLP/HTTP endpoint (e.g. http://localhost:4318)',
|
|
25
|
+
);
|
|
12
26
|
process.exit(0);
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
const port = Number(arg('port') ?? 4780);
|
|
30
|
+
const otlpEndpoint = arg('otlp');
|
|
31
|
+
const headers = parseHeaders(arg('otlp-headers'));
|
|
16
32
|
const server = await createDevtoolsServer({
|
|
17
33
|
port: Number.isFinite(port) ? port : 4780,
|
|
34
|
+
otlp: otlpEndpoint
|
|
35
|
+
? {
|
|
36
|
+
endpoint: otlpEndpoint,
|
|
37
|
+
headers,
|
|
38
|
+
serviceName: arg('otlp-service'),
|
|
39
|
+
onError: (err) => console.warn(`otlp: ${err.message}`),
|
|
40
|
+
}
|
|
41
|
+
: undefined,
|
|
18
42
|
});
|
|
19
43
|
|
|
20
44
|
console.log(`atoll devtools → ${server.url}`);
|
|
21
45
|
console.log(' dashboard: ' + server.url);
|
|
22
46
|
console.log(' ingest: ' + server.url.replace('http://', 'ws://') + '/events');
|
|
47
|
+
if (otlpEndpoint) console.log(' otlp: ' + otlpEndpoint);
|
|
23
48
|
console.log('\nIn the app: import { connectDevtools } from \'@atolljs/devtools\'; connectDevtools()');
|
package/src/broadcast.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
setDevtoolsSink,
|
|
16
16
|
type EmittedDevtoolsEvent,
|
|
17
17
|
} from '@atolljs/core';
|
|
18
|
+
import { answerControl } from './control';
|
|
19
|
+
import { installJankProbe } from './probes';
|
|
18
20
|
import { DEVTOOLS_CHANNEL, type BroadcastMessage, type SessionInfo } from './protocol';
|
|
19
21
|
import type { ConnectDevtoolsOptions, DevtoolsConnection } from './client';
|
|
20
22
|
|
|
@@ -54,6 +56,8 @@ export function connectBroadcast(
|
|
|
54
56
|
if (m.data?.type === 'view') {
|
|
55
57
|
hello();
|
|
56
58
|
for (const events of tail) post({ type: 'batch', session, events });
|
|
59
|
+
} else if (m.data?.type === 'control' && m.data.sessionId === session.id) {
|
|
60
|
+
void answerControl(m.data, (res) => post(res));
|
|
57
61
|
}
|
|
58
62
|
};
|
|
59
63
|
|
|
@@ -69,6 +73,7 @@ export function connectBroadcast(
|
|
|
69
73
|
|
|
70
74
|
if (opts.network !== false) installFetchProbe(globalThis);
|
|
71
75
|
if (opts.memory !== false) installMemoryProbe(globalThis);
|
|
76
|
+
const releaseJank = opts.jank !== false ? installJankProbe() : () => {};
|
|
72
77
|
|
|
73
78
|
setDevtoolsSink((event) => {
|
|
74
79
|
if (closed) return;
|
|
@@ -90,6 +95,7 @@ export function connectBroadcast(
|
|
|
90
95
|
bye();
|
|
91
96
|
setDevtoolsSink(null);
|
|
92
97
|
clearInterval(timer);
|
|
98
|
+
releaseJank();
|
|
93
99
|
if (typeof removeEventListener === 'function') removeEventListener('pagehide', onHide);
|
|
94
100
|
bc.close();
|
|
95
101
|
},
|
package/src/client.ts
CHANGED
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
type EmittedDevtoolsEvent,
|
|
25
25
|
} from '@atolljs/core';
|
|
26
26
|
import { connectBroadcast } from './broadcast';
|
|
27
|
-
import
|
|
27
|
+
import { answerControl, captureEnv } from './control';
|
|
28
|
+
import { installJankProbe } from './probes';
|
|
29
|
+
import type { AppMessage, ClientMessage, SessionInfo } from './protocol';
|
|
28
30
|
|
|
29
31
|
export interface ConnectDevtoolsOptions {
|
|
30
32
|
/**
|
|
@@ -37,7 +39,7 @@ export interface ConnectDevtoolsOptions {
|
|
|
37
39
|
/** Server ingest endpoint — implies websocket transport. */
|
|
38
40
|
url?: string;
|
|
39
41
|
/** Session identity shown in the dashboard. */
|
|
40
|
-
session?: { name?: string; hint?: string };
|
|
42
|
+
session?: { name?: string; hint?: string; framework?: string };
|
|
41
43
|
/** Batch flush interval ms (default 100). */
|
|
42
44
|
flushMs?: number;
|
|
43
45
|
/** Events buffered while disconnected (default 50_000 — oldest dropped). */
|
|
@@ -48,6 +50,12 @@ export interface ConnectDevtoolsOptions {
|
|
|
48
50
|
network?: boolean;
|
|
49
51
|
/** Sample JS heap usage periodically — Chrome/Blink only (default true). */
|
|
50
52
|
memory?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Main-thread jank probe (default true): long frames (`runtime:longframe`,
|
|
55
|
+
* Long Animation Frames or `longtask`) and a rAF fps sampler
|
|
56
|
+
* (`runtime:frames`). Browser main thread only.
|
|
57
|
+
*/
|
|
58
|
+
jank?: boolean;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export interface DevtoolsConnection {
|
|
@@ -70,8 +78,10 @@ const makeSession = (opts: ConnectDevtoolsOptions): SessionInfo => {
|
|
|
70
78
|
return {
|
|
71
79
|
id: `s-${Date.now().toString(36)}-${++sessionSeq}`,
|
|
72
80
|
name: opts.session?.name,
|
|
81
|
+
framework: opts.session?.framework,
|
|
73
82
|
runtime,
|
|
74
83
|
hint,
|
|
84
|
+
env: captureEnv(),
|
|
75
85
|
};
|
|
76
86
|
};
|
|
77
87
|
|
|
@@ -118,6 +128,16 @@ function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): D
|
|
|
118
128
|
flush();
|
|
119
129
|
};
|
|
120
130
|
|
|
131
|
+
const onMessage = (text: string) => {
|
|
132
|
+
let msg: AppMessage;
|
|
133
|
+
try {
|
|
134
|
+
msg = JSON.parse(text);
|
|
135
|
+
} catch {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (msg.type === 'control') void answerControl(msg, (res) => send(res));
|
|
139
|
+
};
|
|
140
|
+
|
|
121
141
|
const isNode = typeof window === 'undefined' || typeof window.document === 'undefined';
|
|
122
142
|
if (typeof WebSocket !== 'undefined') {
|
|
123
143
|
const ws = new WebSocket(url);
|
|
@@ -129,6 +149,7 @@ function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): D
|
|
|
129
149
|
close: () => ws.close(),
|
|
130
150
|
};
|
|
131
151
|
ws.onopen = onOpen;
|
|
152
|
+
ws.onmessage = (m: MessageEvent) => onMessage(String(m.data));
|
|
132
153
|
ws.onclose = () => clearInterval(timer);
|
|
133
154
|
ws.onerror = () => ws.close();
|
|
134
155
|
} else if (isNode) {
|
|
@@ -142,6 +163,7 @@ function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): D
|
|
|
142
163
|
const ws = m.connectNodeWebSocket(url);
|
|
143
164
|
sock = ws;
|
|
144
165
|
ws.onOpen = onOpen;
|
|
166
|
+
ws.onMessage = onMessage;
|
|
145
167
|
ws.onClose = () => clearInterval(timer);
|
|
146
168
|
}).catch(() => {
|
|
147
169
|
/* no net available — sink stays installed but dead */
|
|
@@ -154,6 +176,7 @@ function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): D
|
|
|
154
176
|
|
|
155
177
|
if (opts.network !== false) installFetchProbe(globalThis);
|
|
156
178
|
if (opts.memory !== false) installMemoryProbe(globalThis);
|
|
179
|
+
const releaseJank = opts.jank !== false ? installJankProbe() : () => {};
|
|
157
180
|
|
|
158
181
|
setDevtoolsSink((event) => {
|
|
159
182
|
if (closed) return;
|
|
@@ -173,6 +196,7 @@ function connectWebSocket(opts: ConnectDevtoolsOptions, session: SessionInfo): D
|
|
|
173
196
|
flush();
|
|
174
197
|
sock?.close();
|
|
175
198
|
clearInterval(timer);
|
|
199
|
+
releaseJank();
|
|
176
200
|
},
|
|
177
201
|
};
|
|
178
202
|
}
|
package/src/control.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App side of the control channel: answer a dashboard `control` frame by
|
|
3
|
+
* running the named command (`registerDevtoolsCommand` in @atolljs/core)
|
|
4
|
+
* and replying with a `control-result`. Both transports share this.
|
|
5
|
+
*/
|
|
6
|
+
import { runDevtoolsCommand } from '@atolljs/core';
|
|
7
|
+
import type { ControlRequest, ControlResult, SessionEnv } from './protocol';
|
|
8
|
+
|
|
9
|
+
/** Make a command result transport-safe: JSON round-trip, functions dropped. */
|
|
10
|
+
const jsonSafe = (v: unknown): unknown => {
|
|
11
|
+
if (v === undefined) return undefined;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(JSON.stringify(v));
|
|
14
|
+
} catch {
|
|
15
|
+
return String(v);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const answerControl = async (
|
|
20
|
+
req: ControlRequest,
|
|
21
|
+
reply: (res: ControlResult) => void,
|
|
22
|
+
): Promise<void> => {
|
|
23
|
+
try {
|
|
24
|
+
const result = await runDevtoolsCommand(req.cmd, req.args ?? {});
|
|
25
|
+
reply({ type: 'control-result', sessionId: req.sessionId, id: req.id, ok: true, result: jsonSafe(result) });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
reply({
|
|
28
|
+
type: 'control-result',
|
|
29
|
+
sessionId: req.sessionId,
|
|
30
|
+
id: req.id,
|
|
31
|
+
ok: false,
|
|
32
|
+
error: err instanceof Error ? err.message : String(err),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Runtime facts for the session's `env` — what the audits panel reasons over. */
|
|
38
|
+
export const captureEnv = (): SessionEnv => {
|
|
39
|
+
const g = globalThis as {
|
|
40
|
+
crossOriginIsolated?: boolean;
|
|
41
|
+
navigator?: { hardwareConcurrency?: number; userAgent?: string };
|
|
42
|
+
process?: { version?: string };
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
crossOriginIsolated: g.crossOriginIsolated,
|
|
46
|
+
sharedArrayBuffer: typeof SharedArrayBuffer !== 'undefined',
|
|
47
|
+
hardwareConcurrency: g.navigator?.hardwareConcurrency,
|
|
48
|
+
userAgent: g.navigator?.userAgent,
|
|
49
|
+
node: g.navigator ? undefined : g.process?.version,
|
|
50
|
+
};
|
|
51
|
+
};
|
package/src/nodeWs.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface NodeWebSocket {
|
|
|
21
21
|
close(): void;
|
|
22
22
|
onOpen: (() => void) | null;
|
|
23
23
|
onClose: (() => void) | null;
|
|
24
|
+
onMessage: ((text: string) => void) | null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Connect a WebSocket to `url` — handshake, then framed text IO. */
|
|
@@ -62,6 +63,7 @@ export function connectNodeWebSocket(url: string): NodeWebSocket {
|
|
|
62
63
|
},
|
|
63
64
|
onOpen: null,
|
|
64
65
|
onClose: null,
|
|
66
|
+
onMessage: null,
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
const die = () => {
|
|
@@ -108,7 +110,11 @@ export function connectNodeWebSocket(url: string): NodeWebSocket {
|
|
|
108
110
|
if (opcode === OPCODES.pong) continue;
|
|
109
111
|
if (opcode === OPCODES.text || opcode === OPCODES.continuation) {
|
|
110
112
|
fragments.push(payload);
|
|
111
|
-
if (fin)
|
|
113
|
+
if (fin) {
|
|
114
|
+
const text = Buffer.concat(fragments).toString('utf8');
|
|
115
|
+
fragments = [];
|
|
116
|
+
conn.onMessage?.(text);
|
|
117
|
+
}
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
});
|
package/src/otel.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@atolljs/devtools/otel` — ship atoll instrumentation to any OpenTelemetry
|
|
3
|
+
* backend (Collector, Jaeger, Tempo, Honeycomb, Grafana) over OTLP/HTTP
|
|
4
|
+
* with JSON encoding. Zero dependencies: `fetch` + the mapping in otelMap.ts.
|
|
5
|
+
*
|
|
6
|
+
* import { exportOtel } from '@atolljs/devtools/otel';
|
|
7
|
+
* const otel = exportOtel({ serviceName: 'my-app' }); // → http://localhost:4318
|
|
8
|
+
*
|
|
9
|
+
* Installs as an additive devtools listener (`addDevtoolsSink`), so it runs
|
|
10
|
+
* alongside `connectDevtools()` or alone. Call it before pools spawn: like
|
|
11
|
+
* the dashboard transports, worker-side events forward only when a
|
|
12
|
+
* listener existed at the worker's INIT handshake.
|
|
13
|
+
*/
|
|
14
|
+
import { addDevtoolsSink, installFetchProbe, installMemoryProbe } from '@atolljs/core';
|
|
15
|
+
import { OtelMapper, type OtelMapperOptions, type OtelSignals } from './otelMap';
|
|
16
|
+
import {
|
|
17
|
+
createOtelPipeline,
|
|
18
|
+
createOtlpSender,
|
|
19
|
+
localResource,
|
|
20
|
+
type OtlpSenderOptions,
|
|
21
|
+
type ResourceAttributes,
|
|
22
|
+
} from './otelTransport';
|
|
23
|
+
import { installJankProbe } from './probes';
|
|
24
|
+
|
|
25
|
+
export interface ExportOtelOptions extends OtlpSenderOptions {
|
|
26
|
+
/** Resource `service.name` (default 'unknown_service'). */
|
|
27
|
+
serviceName?: string;
|
|
28
|
+
/** Extra resource attributes, merged over the detected ones. */
|
|
29
|
+
resource?: ResourceAttributes;
|
|
30
|
+
/** Signals to export: all on by default. */
|
|
31
|
+
signals?: OtelSignals;
|
|
32
|
+
/** Periodic flush interval (default 5000ms). */
|
|
33
|
+
flushIntervalMs?: number;
|
|
34
|
+
/** Spans / log records per request (default 512). */
|
|
35
|
+
maxBatch?: number;
|
|
36
|
+
/** Queued spans and log records each before the oldest drop (default 2048). */
|
|
37
|
+
maxQueue?: number;
|
|
38
|
+
/** Series per metric before new attribute sets drop (default 2000). */
|
|
39
|
+
maxSeries?: number;
|
|
40
|
+
/** Patch global fetch for `net:fetch` spans (default true). */
|
|
41
|
+
network?: boolean;
|
|
42
|
+
/** Sample JS heap for `atoll.runtime.heap` (default true). */
|
|
43
|
+
memory?: boolean;
|
|
44
|
+
/** Browser main thread: long-frame / fps probe (default true). */
|
|
45
|
+
jank?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OtelExporter {
|
|
49
|
+
/** Ship everything queued now (plus a metrics snapshot). Never rejects. */
|
|
50
|
+
flush(): Promise<void>;
|
|
51
|
+
/** Uninstall the listener and lifecycle hooks, then flush. Never rejects. */
|
|
52
|
+
close(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function exportOtel(opts: ExportOtelOptions = {}): OtelExporter {
|
|
56
|
+
// The sender captures the global fetch now, before the probe below wraps
|
|
57
|
+
// it, so the exporter's own posts never become net:fetch spans.
|
|
58
|
+
const sender = createOtlpSender(opts);
|
|
59
|
+
const endpoint = sender.endpoint;
|
|
60
|
+
const perf = globalThis.performance;
|
|
61
|
+
const originMs = perf.timeOrigin ?? Date.now() - perf.now();
|
|
62
|
+
const mapperOpts: OtelMapperOptions = {
|
|
63
|
+
resource: localResource(opts.serviceName, opts.resource),
|
|
64
|
+
originMs,
|
|
65
|
+
signals: opts.signals,
|
|
66
|
+
maxQueue: opts.maxQueue,
|
|
67
|
+
maxSeries: opts.maxSeries,
|
|
68
|
+
// An earlier probe (connectDevtools) may already wrap the captured fetch.
|
|
69
|
+
ignoreUrl: (url) => url.startsWith(endpoint),
|
|
70
|
+
};
|
|
71
|
+
const pipeline = createOtelPipeline({
|
|
72
|
+
sender,
|
|
73
|
+
newMapper: () => new OtelMapper(mapperOpts),
|
|
74
|
+
flushIntervalMs: opts.flushIntervalMs,
|
|
75
|
+
maxBatch: opts.maxBatch,
|
|
76
|
+
signals: opts.signals,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const remove = addDevtoolsSink((ev) => pipeline.ingest(ev, originMs + perf.now()));
|
|
80
|
+
|
|
81
|
+
if (opts.network !== false) installFetchProbe(globalThis);
|
|
82
|
+
if (opts.memory !== false) installMemoryProbe(globalThis);
|
|
83
|
+
const releaseJank = opts.jank !== false ? installJankProbe() : () => {};
|
|
84
|
+
|
|
85
|
+
// Last-chance flushes: keepalive fetch survives page unload (sendBeacon
|
|
86
|
+
// can't carry auth headers or a JSON content-type cross-origin).
|
|
87
|
+
type Listenable = {
|
|
88
|
+
addEventListener?: (t: string, l: () => void) => void;
|
|
89
|
+
removeEventListener?: (t: string, l: () => void) => void;
|
|
90
|
+
};
|
|
91
|
+
const g = globalThis as Listenable & {
|
|
92
|
+
document?: Listenable & { visibilityState?: string };
|
|
93
|
+
process?: {
|
|
94
|
+
once?: (e: string, l: () => void) => void;
|
|
95
|
+
off?: (e: string, l: () => void) => void;
|
|
96
|
+
versions?: { node?: string };
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const onHide = () => {
|
|
100
|
+
if (g.document?.visibilityState === 'hidden') void pipeline.flush({ keepalive: true });
|
|
101
|
+
};
|
|
102
|
+
const onPageHide = () => void pipeline.flush({ keepalive: true });
|
|
103
|
+
const inBrowser = typeof g.document !== 'undefined' && typeof g.addEventListener === 'function';
|
|
104
|
+
if (inBrowser) {
|
|
105
|
+
g.addEventListener!('pagehide', onPageHide);
|
|
106
|
+
g.document!.addEventListener?.('visibilitychange', onHide);
|
|
107
|
+
}
|
|
108
|
+
const onBeforeExit = () => void pipeline.flush();
|
|
109
|
+
const inNode = !inBrowser && typeof g.process?.once === 'function' && g.process.versions?.node !== undefined;
|
|
110
|
+
if (inNode) g.process!.once!('beforeExit', onBeforeExit);
|
|
111
|
+
|
|
112
|
+
let closed = false;
|
|
113
|
+
return {
|
|
114
|
+
flush: () => pipeline.flush(),
|
|
115
|
+
async close() {
|
|
116
|
+
if (closed) return;
|
|
117
|
+
closed = true;
|
|
118
|
+
remove();
|
|
119
|
+
releaseJank();
|
|
120
|
+
if (inBrowser) {
|
|
121
|
+
g.removeEventListener?.('pagehide', onPageHide);
|
|
122
|
+
g.document?.removeEventListener?.('visibilitychange', onHide);
|
|
123
|
+
}
|
|
124
|
+
if (inNode) g.process!.off?.('beforeExit', onBeforeExit);
|
|
125
|
+
await pipeline.close();
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export { OtelMapper, SEVERITY, newSpanId, newTraceId, toUnixNano } from './otelMap';
|
|
131
|
+
export type { KeyValue, OtelMapperOptions, OtelSignals, OtlpLogRecord, OtlpMetric, OtlpSpan } from './otelMap';
|
|
132
|
+
export { createOtelPipeline, createOtlpSender, localResource, sessionResource } from './otelTransport';
|
|
133
|
+
export type {
|
|
134
|
+
OtelPipeline,
|
|
135
|
+
OtelPipelineOptions,
|
|
136
|
+
OtlpSender,
|
|
137
|
+
OtlpSenderOptions,
|
|
138
|
+
OtlpSignal,
|
|
139
|
+
ResourceAttributes,
|
|
140
|
+
} from './otelTransport';
|