autotel-devtools 8.0.0 → 8.1.1

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 CHANGED
@@ -149,12 +149,29 @@ process — point them at the bound port, or free the original.
149
149
  - ✅ Error aggregation and grouping
150
150
  - ✅ Service map visualization
151
151
  - ✅ Resources view (derived from telemetry)
152
+ - ✅ GenAI run summaries + narrated walkthrough
152
153
  - ✅ Search with debounce (300ms)
153
154
  - ✅ Configurable telemetry limits (env vars)
154
155
  - ✅ Widget position persistence (localStorage)
155
156
  - ✅ Export traces as JSON
156
157
  - ✅ Custom element support (`<autotel-devtools>`)
157
158
 
159
+ ### GenAI: read an agent run at a glance
160
+
161
+ When your app emits OpenTelemetry GenAI spans (Vercel AI SDK, Pydantic AI, OpenAI
162
+ Agents, Anthropic, Google GenAI, LangChain, …), the **GenAI** tab gives two extras
163
+ on top of the per-span detail:
164
+
165
+ - A **run summary strip** sits above the detail for any multi-span run — total
166
+ cost (table-priced; a trailing `+` marks a lower bound when some calls are
167
+ unpriced), input→output tokens, reasoning tokens, model calls, tool
168
+ executions, sub-agents, duration and errors.
169
+ - An **Explain run** button steps through the run in chronological order with
170
+ plain-language narration of each step. Auto-play or step manually with the
171
+ arrow keys / Space (Esc exits); clicking a span jumps the tour to that step.
172
+ Useful for showing a teammate or a client exactly what the agent did, which
173
+ tools it called, and where the cost went.
174
+
158
175
  ## Configuration
159
176
 
160
177
  ### Environment Variables
@@ -240,6 +257,26 @@ await expect
240
257
  .toBeGreaterThan(0);
241
258
  ```
242
259
 
260
+ These read-back calls run from Node (no `Origin` header), so they are unaffected
261
+ by the origin guard below.
262
+
263
+ ## Read-surface origin guard
264
+
265
+ OTLP **ingestion** (`POST /v1/{traces,logs,metrics}`), `GET /widget.js` and
266
+ `GET /healthz` are open to any origin — browser apps on arbitrary dev origins
267
+ must be able to send telemetry and load the embeddable widget. The **read**
268
+ surface is not: `GET /v1/traces`, `DELETE /v1/traces` and the `/ws` WebSocket are
269
+ origin-checked so a web page you happen to be visiting can't `fetch()` or stream
270
+ your locally captured prompts, responses and tokens.
271
+
272
+ - A non-loopback `Origin` (a cross-origin browser read) is rejected with `403`.
273
+ - When bound to a loopback host (the default), a non-loopback `Host` (DNS
274
+ rebinding) is also rejected. `--host 0.0.0.0` opts into network exposure and
275
+ applies only the `Origin` check.
276
+
277
+ The embedded widget keeps working — it connects from a loopback origin
278
+ (`http://localhost:<your-app-port>`). Server-side reads with no `Origin` pass.
279
+
243
280
  ## License
244
281
 
245
282
  MIT
package/dist/cli.cjs CHANGED
@@ -353,6 +353,40 @@ function appendManyWithLimit(items, incoming, limit) {
353
353
  return next.length > limit ? next.slice(next.length - limit) : next;
354
354
  }
355
355
 
356
+ // src/server/origin-guard.ts
357
+ var LOOPBACK_IPV6 = /* @__PURE__ */ new Set(["::1", "0:0:0:0:0:0:0:1"]);
358
+ function isLoopbackHostname(hostname) {
359
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
360
+ return h === "localhost" || /^127\./.test(h) || LOOPBACK_IPV6.has(h);
361
+ }
362
+ function hostnameFromHostHeader(host) {
363
+ const h = host.trim();
364
+ if (h.startsWith("[")) {
365
+ const end = h.indexOf("]");
366
+ return end > 0 ? h.slice(1, end) : h;
367
+ }
368
+ const colon = h.indexOf(":");
369
+ return colon === -1 ? h : h.slice(0, colon);
370
+ }
371
+ function hostHeaderIsLoopback(host) {
372
+ return isLoopbackHostname(hostnameFromHostHeader(host));
373
+ }
374
+ function originIsLoopback(origin) {
375
+ try {
376
+ return isLoopbackHostname(new URL(origin).hostname);
377
+ } catch {
378
+ return false;
379
+ }
380
+ }
381
+ function allowSensitiveRequest(headers, loopbackOnly) {
382
+ const { origin, host } = headers;
383
+ if (origin && origin.length > 0 && !originIsLoopback(origin)) return false;
384
+ if (loopbackOnly && host && host.length > 0 && !hostHeaderIsLoopback(host)) {
385
+ return false;
386
+ }
387
+ return true;
388
+ }
389
+
356
390
  // src/server/server.ts
357
391
  var DevtoolsServer = class {
358
392
  wss;
@@ -372,7 +406,12 @@ var DevtoolsServer = class {
372
406
  this._port = options.port ?? 4318;
373
407
  this.onData = options.onData;
374
408
  this.httpServer = options.server ?? http.createServer();
375
- this.wss = new ws.WebSocketServer({ server: this.httpServer, path: options.path ?? "/ws" });
409
+ const loopbackOnly = options.host == null || hostHeaderIsLoopback(options.host);
410
+ this.wss = new ws.WebSocketServer({
411
+ server: this.httpServer,
412
+ path: options.path ?? "/ws",
413
+ verifyClient: ({ origin, req }) => allowSensitiveRequest({ origin, host: req.headers.host }, loopbackOnly)
414
+ });
376
415
  this.wss.on("error", (err) => {
377
416
  if (this.httpServer.listening) throw err;
378
417
  });
@@ -406,6 +445,7 @@ var DevtoolsServer = class {
406
445
  }
407
446
  addTrace(trace) {
408
447
  const existing = this.traces.find((t) => t.traceId === trace.traceId);
448
+ const merged = existing ?? trace;
409
449
  if (existing) {
410
450
  const existingSpanIds = new Set(existing.spans.map((s) => s.spanId));
411
451
  for (const span of trace.spans) {
@@ -417,6 +457,14 @@ var DevtoolsServer = class {
417
457
  existing.endTime = Math.max(existing.endTime, trace.endTime);
418
458
  existing.duration = existing.endTime - existing.startTime;
419
459
  if (trace.status === "ERROR") existing.status = "ERROR";
460
+ const root = existing.spans.find((s) => !s.parentSpanId);
461
+ if (root) {
462
+ existing.rootSpan = root;
463
+ const rootService = root.attributes?.["service.name"];
464
+ if (typeof rootService === "string" && rootService.length > 0) {
465
+ existing.service = rootService;
466
+ }
467
+ }
420
468
  } else {
421
469
  this.traces = appendWithLimit(
422
470
  this.traces,
@@ -425,7 +473,7 @@ var DevtoolsServer = class {
425
473
  );
426
474
  }
427
475
  this.errorAggregator.addErrorsFromTrace(trace);
428
- this.broadcast({ traces: [trace], metrics: [], logs: [], errors: this.errorAggregator.getErrorGroups() });
476
+ this.broadcast({ traces: [merged], metrics: [], logs: [], errors: this.errorAggregator.getErrorGroups() });
429
477
  }
430
478
  addTraces(traces) {
431
479
  for (const trace of traces) this.addTrace(trace);
@@ -977,7 +1025,8 @@ function findPackageRoot() {
977
1025
  }
978
1026
  return dir;
979
1027
  }
980
- var FULLPAGE_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>autotel-devtools</title><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;width:100%;overflow:hidden}</style></head><body><script src="/widget.js?mode=fullpage"></script></body></html>`;
1028
+ var DEVTOOLS_FAVICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f172a"/><text x="32" y="41" text-anchor="middle" font-size="32">\u{1F6F0}\uFE0F</text></svg>';
1029
+ var FULLPAGE_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>autotel-devtools</title><link rel="icon" href="/favicon.svg" type="image/svg+xml"><style>*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;width:100%;overflow:hidden}</style></head><body><script src="/widget.js?mode=fullpage"></script></body></html>`;
981
1030
  var cachedVersion = null;
982
1031
  function getVersion() {
983
1032
  if (cachedVersion !== null) return cachedVersion;
@@ -1011,8 +1060,10 @@ function getWidgetJs() {
1011
1060
  }
1012
1061
  return cachedWidgetJs;
1013
1062
  }
1014
- function attachDevtoolsRoutes(httpServer, devtools) {
1063
+ function attachDevtoolsRoutes(httpServer, devtools, options = {}) {
1064
+ const loopbackOnly = options.loopbackOnly ?? true;
1015
1065
  httpServer.on("request", async (req, res) => {
1066
+ if (req.headers.upgrade?.toLowerCase() === "websocket") return;
1016
1067
  res.setHeader("Access-Control-Allow-Origin", "*");
1017
1068
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
1018
1069
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
@@ -1035,6 +1086,15 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1035
1086
  res.end(js);
1036
1087
  return;
1037
1088
  }
1089
+ if (req.method === "GET" && (url === "/favicon.svg" || url === "/favicon.ico")) {
1090
+ res.writeHead(200, {
1091
+ "Content-Type": "image/svg+xml; charset=utf-8",
1092
+ "Cache-Control": "public, max-age=86400",
1093
+ "Content-Length": Buffer.byteLength(DEVTOOLS_FAVICON_SVG)
1094
+ });
1095
+ res.end(DEVTOOLS_FAVICON_SVG);
1096
+ return;
1097
+ }
1038
1098
  if (req.method === "GET" && url === "/healthz") {
1039
1099
  sendJson(res, 200, {
1040
1100
  ok: true,
@@ -1045,11 +1105,19 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1045
1105
  return;
1046
1106
  }
1047
1107
  if (req.method === "GET" && url === "/v1/traces") {
1108
+ if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
1109
+ sendJson(res, 403, { error: "Forbidden" });
1110
+ return;
1111
+ }
1048
1112
  const data = devtools.getCurrentData();
1049
1113
  sendJson(res, 200, { traces: data.traces, count: data.traces.length });
1050
1114
  return;
1051
1115
  }
1052
1116
  if (req.method === "DELETE" && url === "/v1/traces") {
1117
+ if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
1118
+ sendJson(res, 403, { error: "Forbidden" });
1119
+ return;
1120
+ }
1053
1121
  devtools.clearData();
1054
1122
  sendJson(res, 200, { cleared: true });
1055
1123
  return;
@@ -1290,13 +1358,14 @@ async function main() {
1290
1358
  process.exit(0);
1291
1359
  }
1292
1360
  const httpServer = http.createServer();
1293
- const wsServer = new DevtoolsServer({ server: httpServer, verbose: true });
1294
- attachDevtoolsRoutes(httpServer, wsServer);
1361
+ const loopbackOnly = hostHeaderIsLoopback(options.host);
1362
+ const wsServer = new DevtoolsServer({ server: httpServer, host: options.host, verbose: true });
1363
+ attachDevtoolsRoutes(httpServer, wsServer, { loopbackOnly });
1295
1364
  const listeners = listenLoopbackDualStack({
1296
1365
  primary: httpServer,
1297
1366
  port: options.port,
1298
1367
  host: options.host,
1299
- attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
1368
+ attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer, { loopbackOnly })
1300
1369
  });
1301
1370
  const { addresses, warnings, port: boundPort } = await listeners.ready;
1302
1371
  if (boundPort !== options.port) {