autotel-devtools 7.0.0 → 8.1.0

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
  });
@@ -1011,7 +1050,8 @@ function getWidgetJs() {
1011
1050
  }
1012
1051
  return cachedWidgetJs;
1013
1052
  }
1014
- function attachDevtoolsRoutes(httpServer, devtools) {
1053
+ function attachDevtoolsRoutes(httpServer, devtools, options = {}) {
1054
+ const loopbackOnly = options.loopbackOnly ?? true;
1015
1055
  httpServer.on("request", async (req, res) => {
1016
1056
  res.setHeader("Access-Control-Allow-Origin", "*");
1017
1057
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
@@ -1045,11 +1085,19 @@ function attachDevtoolsRoutes(httpServer, devtools) {
1045
1085
  return;
1046
1086
  }
1047
1087
  if (req.method === "GET" && url === "/v1/traces") {
1088
+ if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
1089
+ sendJson(res, 403, { error: "Forbidden" });
1090
+ return;
1091
+ }
1048
1092
  const data = devtools.getCurrentData();
1049
1093
  sendJson(res, 200, { traces: data.traces, count: data.traces.length });
1050
1094
  return;
1051
1095
  }
1052
1096
  if (req.method === "DELETE" && url === "/v1/traces") {
1097
+ if (!allowSensitiveRequest(req.headers, loopbackOnly)) {
1098
+ sendJson(res, 403, { error: "Forbidden" });
1099
+ return;
1100
+ }
1053
1101
  devtools.clearData();
1054
1102
  sendJson(res, 200, { cleared: true });
1055
1103
  return;
@@ -1290,13 +1338,14 @@ async function main() {
1290
1338
  process.exit(0);
1291
1339
  }
1292
1340
  const httpServer = http.createServer();
1293
- const wsServer = new DevtoolsServer({ server: httpServer, verbose: true });
1294
- attachDevtoolsRoutes(httpServer, wsServer);
1341
+ const loopbackOnly = hostHeaderIsLoopback(options.host);
1342
+ const wsServer = new DevtoolsServer({ server: httpServer, host: options.host, verbose: true });
1343
+ attachDevtoolsRoutes(httpServer, wsServer, { loopbackOnly });
1295
1344
  const listeners = listenLoopbackDualStack({
1296
1345
  primary: httpServer,
1297
1346
  port: options.port,
1298
1347
  host: options.host,
1299
- attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer)
1348
+ attachSecondary: (s) => attachDevtoolsRoutes(s, wsServer, { loopbackOnly })
1300
1349
  });
1301
1350
  const { addresses, warnings, port: boundPort } = await listeners.ready;
1302
1351
  if (boundPort !== options.port) {