autotel-devtools 22.0.0 → 23.0.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.
Files changed (46) hide show
  1. package/README.md +183 -28
  2. package/dist/cli.cjs +80 -13
  3. package/dist/cli.js +80 -13
  4. package/dist/compile-JDFHUCo5.d.cts +177 -0
  5. package/dist/compile-JDFHUCo5.d.ts +177 -0
  6. package/dist/{error-aggregator-D8VKciLY.d.ts → error-aggregator-B52JI8jL.d.ts} +1 -1
  7. package/dist/{error-aggregator-DCEKMOm3.d.cts → error-aggregator-DSwUvgFF.d.cts} +1 -1
  8. package/dist/exporter-BrEcnzoT.d.ts +546 -0
  9. package/dist/exporter-C4uKgo7l.d.cts +546 -0
  10. package/dist/fullpage.global.js +39 -0
  11. package/dist/grpc-CSWjMRiB.cjs +82 -0
  12. package/dist/grpc-CZEDUYCM.js +77 -0
  13. package/dist/http-Cqm_MAtc.cjs +3942 -0
  14. package/dist/http-Dbd9TIYU.js +3776 -0
  15. package/dist/index.cjs +25 -7
  16. package/dist/index.d.cts +30 -3
  17. package/dist/index.d.ts +30 -3
  18. package/dist/index.js +25 -7
  19. package/dist/{listen-DBfsfcdd.js → listen-D-lLgfro.js} +5 -2
  20. package/dist/{listen-CEJ3nYJf.cjs → listen-l09RRHht.cjs} +5 -2
  21. package/dist/parse-BRlosZft.cjs +642 -0
  22. package/dist/parse-D_RmPPQs.js +612 -0
  23. package/dist/query/index.cjs +8 -0
  24. package/dist/query/index.d.cts +16 -0
  25. package/dist/query/index.d.ts +16 -0
  26. package/dist/query/index.js +3 -0
  27. package/dist/server/exporter.d.cts +1 -1
  28. package/dist/server/exporter.d.ts +1 -1
  29. package/dist/server/index.cjs +6 -2
  30. package/dist/server/index.d.cts +19 -6
  31. package/dist/server/index.d.ts +18 -5
  32. package/dist/server/index.js +3 -2
  33. package/dist/types-B0tjwFqj.d.cts +107 -0
  34. package/dist/types-DM8y4A9Z.d.ts +107 -0
  35. package/dist/widget.global.js +15 -24
  36. package/dist/wire/index.cjs +7 -0
  37. package/dist/wire/index.d.cts +26 -0
  38. package/dist/wire/index.d.ts +26 -0
  39. package/dist/wire/index.js +3 -0
  40. package/dist/wire-2Rmfg6IT.js +51 -0
  41. package/dist/wire-CHU1PkMo.cjs +75 -0
  42. package/package.json +21 -5
  43. package/dist/exporter-Dt4kx128.d.cts +0 -207
  44. package/dist/exporter-Due9Rd4s.d.ts +0 -207
  45. package/dist/http-CNZMrnzv.js +0 -1453
  46. package/dist/http-CXSzX4ee.cjs +0 -1607
package/README.md CHANGED
@@ -24,11 +24,26 @@ Standalone OTLP receiver with web UI for local development. Think TanStack Devto
24
24
  │ │ ├── GET /healthz → Health check │ │
25
25
  │ │ └── WS /ws ←→ WebSocket │ │
26
26
  │ └───────────────────────────────────────┘ │
27
+ │ OTLP/gRPC receiver (port 4317) │
27
28
  └─────────────────────────────────────────────┘
28
29
  ```
29
30
 
30
31
  ## Quick Start
31
32
 
33
+ ### Installing with pnpm
34
+
35
+ This package pulls in `esbuild` and `protobufjs`, both of which run install
36
+ scripts. pnpm 10+ blocks those by default and then refuses to run anything else
37
+ until you decide about them, so `pnpm install && pnpm start` fails on a fresh
38
+ clone with a deps-status error rather than an obvious one. Approve them once in
39
+ `pnpm-workspace.yaml`:
40
+
41
+ ```yaml
42
+ allowBuilds:
43
+ esbuild: true
44
+ protobufjs: true
45
+ ```
46
+
32
47
  ### Standalone Mode
33
48
 
34
49
  ```bash
@@ -42,17 +57,23 @@ node app.js
42
57
  ```
43
58
 
44
59
  The endpoints accept **both OTLP/JSON and OTLP/protobuf** (`application/x-protobuf`),
45
- selected automatically from the request `Content-Type`. That means SDKs that default
46
- to protobuf over OTLP HTTP, including the Python, Java, and Go OpenTelemetry SDKs.
47
- Work without any extra configuration:
60
+ chosen from the request `Content-Type`. SDKs that default to protobuf over OTLP
61
+ HTTP, including the Python, Java, and Go OpenTelemetry SDKs, work with no extra
62
+ configuration:
48
63
 
49
64
  ```bash
50
- # Python / Java / Go SDKs default to http/protobuf — just point them at the receiver
65
+ # Python / Java / Go SDKs default to http/protobuf; point them at the receiver
51
66
  OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 python app.py
52
67
  ```
53
68
 
54
69
  Open http://localhost:4318 to see traces, logs, and metrics.
55
70
 
71
+ SDKs configured for OTLP/gRPC can use the standard endpoint directly:
72
+
73
+ ```bash
74
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 python app.py
75
+ ```
76
+
56
77
  ### Embedded Widget
57
78
 
58
79
  Add the widget to your web app:
@@ -111,6 +132,7 @@ const myFunction = withTracing({ name: 'example.run' })((ctx) => async () => {
111
132
 
112
133
  - **DevtoolsServer** - WebSocket server + in-memory data store
113
134
  - **HTTP Routes** - OTLP receivers for traces/logs/metrics (JSON + protobuf)
135
+ - **gRPC Receiver** - canonical OTLP trace, log, and metric services on port 4317
114
136
 
115
137
  #### Detecting the receiver
116
138
 
@@ -145,14 +167,18 @@ process. Point them at the bound port, or free the original.
145
167
 
146
168
  - ✅ Real-time OTLP ingestion (traces, logs, metrics)
147
169
  - ✅ WebSocket streaming with history replay
148
- - ✅ Traces view with waterfall + flame graph
149
- - ✅ Logs view with severity/resource filtering
170
+ - ✅ **Query language** over traces and logs, run as SQL against a sqlite store (see below)
171
+ - ✅ **Persistence**: `--db` keeps telemetry across restarts; retention applies count and byte caps
172
+ - ✅ Traces view with waterfall (tree connectors, event markers, critical path) + flame graph
173
+ - ✅ Logs view with a query bar and severity filtering
174
+ - ✅ **Metrics view**: server-reduced time series, merged histogram distributions, interpolated quantiles, sparklines, and capped exemplars that open the trace behind a spike
150
175
  - ✅ Error aggregation and grouping
151
176
  - ✅ Service map visualization
152
177
  - ✅ Resources view (derived from telemetry)
153
178
  - ✅ GenAI run summaries + narrated walkthrough
154
179
  - ✅ **Agents view**: observe coding agents (Claude Code, opencode) from their OTel metrics + log events
155
- - ✅ Search with debounce (300ms)
180
+ - ✅ Global time window shared by every tab
181
+ - ✅ Live tail that freezes while you read, with a "N new" pill to catch up
156
182
  - ✅ Configurable telemetry limits (env vars)
157
183
  - ✅ Widget position persistence (localStorage)
158
184
  - ✅ Export traces as JSON
@@ -171,8 +197,8 @@ on top of the per-span detail:
171
197
  - An **Explain run** button steps through the run in chronological order with
172
198
  plain-language narration of each step. Auto-play or step manually with the
173
199
  arrow keys / Space (Esc exits); clicking a span jumps the tour to that step.
174
- Useful for showing a teammate or a client exactly what the agent did, which
175
- tools it called, and where the cost went.
200
+ Use it to show a teammate or a client what the agent did, which tools it
201
+ called, and where the cost went.
176
202
 
177
203
  ### Agents: observe Claude Code (and other coding agents)
178
204
 
@@ -187,9 +213,9 @@ One command starts the receiver _and_ launches Claude Code wired to it:
187
213
  npx autotel-devtools claude
188
214
  ```
189
215
 
190
- This sets the telemetry env for a live local view. OTLP **`http/protobuf`** to
191
- this receiver (not the gRPC setup in most guides, which this receiver doesn't
192
- speak), 1s export intervals, and `session.id` kept on metrics. Then open the UI
216
+ This sets the telemetry env for a live local view: OTLP **`http/protobuf`**, 1s
217
+ export intervals, and `session.id` kept on metrics. The receiver also accepts
218
+ standard OTLP/gRPC on `:4317`. Then open the UI
193
219
  and switch to **Agents**.
194
220
 
195
221
  - `--print-env`: print the env block instead of launching (for managed-settings
@@ -201,17 +227,116 @@ decisions), a **rollup** (cost, tokens, requests, lines changed), and breakdowns
201
227
  by **tool category**, **MCP server** (`mcp__server__tool`), **sub-agent** (`Task`)
202
228
  and **skill** (`Skill`). Plus an aggregate strip across all sessions. Cost uses
203
229
  the agent's reported `cost_usd`, falling back to a token estimate (badged). MCP
204
- protocol internals are out of scope here. That's `autotel-mcp-instrumentation`.
230
+ protocol internals live in `autotel-mcp-instrumentation`.
231
+
232
+ ## Querying
233
+
234
+ The Traces and Logs tabs take a query, which the server compiles to SQL and runs
235
+ against everything it has kept, rather than only what is on screen.
236
+
237
+ ```
238
+ service = api AND duration > 100
239
+ name CONTAINS checkout
240
+ http.status_code = 500
241
+ service IN [api, web, worker]
242
+ name REGEXP "^GET /users/[0-9]+$"
243
+ severity_number >= 17 # logs: error and above
244
+ parent_span_id = NULL # root spans only
245
+ "user id" = "u-42" # quote a key a bare word can't spell
246
+ checkout # bare words are free text
247
+ ```
248
+
249
+ **Fields.** Traces: `service`, `name`, `kind`, `duration`, `status`, `trace_id`,
250
+ `span_id`, `parent_span_id`. Logs: `service`, `severity`, `severity_number`,
251
+ `trace_id`, `span_id`, `body`. **Anything else is looked up as an attribute**, so
252
+ every attribute your services emit is queryable without being declared.
253
+
254
+ `severity` and `severity_number` both exist on purpose: the text is what you
255
+ read, but "error and above" is a numeric comparison and string ordering cannot
256
+ express it.
257
+
258
+ **Operators.** `=` `!=` `>` `<` `>=` `<=` for comparison; `CONTAINS`,
259
+ `NOT CONTAINS`, `^` (starts with), `$` (ends with), `REGEXP` / `=~`,
260
+ `NOT REGEXP` / `!~` for text; `IN` / `NOT IN` with `[…]`; `= NULL` and
261
+ `!= NULL` for presence. Combine with `AND`, `OR` and parentheses. Two
262
+ conditions side by side mean `AND`.
263
+
264
+ Values are always sent as bound parameters and field names always come from a
265
+ schema, so nothing you type reaches the SQL string.
266
+
267
+ ### The HTTP API
268
+
269
+ The same query the UI runs is available over HTTP, so a test can assert on what
270
+ a run actually emitted instead of on a log line. Requests to the `/api/*`
271
+ endpoints must come from loopback, or the server answers `403 Forbidden`.
272
+
273
+ | Endpoint | Body / result |
274
+ | ------------------------------ | ---------------------------------------------------------------- |
275
+ | `POST /api/query/traces` | `{ query, window?, limit?, cursor? }` → `{ traces, nextCursor }` |
276
+ | `POST /api/query/logs` | same shape, over logs |
277
+ | `POST /api/query/errors` | same shape, error spans only |
278
+ | `POST /api/query/metrics` | same shape, over metric series |
279
+ | `GET /api/query/traces/fields` | `{ fields }` - the bare field names a query can use |
280
+ | `GET /api/stats` | `{ traceCount, spanCount, logCount, bytesUsed, maxBytes, … }` |
281
+ | `GET /v1/traces` | every trace held, no query |
282
+ | `DELETE /v1/traces` | `{ cleared: true }` - empty the store between tests |
283
+ | `GET /healthz` | `{ ok, service, version, clients }` |
284
+
285
+ `query` takes the language above, so anything the Traces tab can filter on, a
286
+ script can too. Each returned trace carries `traceId`, `service`, `rootSpan`,
287
+ `spans`, `duration` and `status`.
288
+
289
+ ### Asserting on traces in CI
290
+
291
+ Clear the store, run the thing, then ask what it emitted. A query count is a far
292
+ steadier assertion than a duration:
293
+
294
+ ```bash
295
+ curl -sX DELETE http://127.0.0.1:4318/v1/traces
296
+ node app.js
297
+
298
+ curl -s -X POST http://127.0.0.1:4318/api/query/traces \
299
+ -H 'content-type: application/json' \
300
+ -d '{"query":"name = \"GET /feed\"","limit":1}' \
301
+ | jq -e '.traces[0].spans | length == 2'
302
+ ```
303
+
304
+ That fails the moment someone reintroduces an N+1 on `/feed`, and it does not
305
+ care how fast the machine running CI happens to be.
306
+
307
+ ### Live tail and the time window
308
+
309
+ The list follows new data by default. It **freezes** the moment you type a
310
+ query, scroll back, select a row, or bound the time window, and a "N new" pill
311
+ counts the matches instead of reordering rows under you. Click the pill to catch
312
+ up. There is no mode to manage: freezing follows from what you did.
313
+
314
+ The time window is one control shared by every tab. Presets track _now_ rather
315
+ than freezing when clicked, and **"All time" means "I haven't said"**: a view
316
+ may fit itself to its own data then, but a window you choose is never widened or
317
+ cropped, because an empty 15-minute window is the answer.
318
+
319
+ ### Reading it from a CLI or an agent
320
+
321
+ The same store backs `autotel-mcp`'s `devtools` backend, so `autotel diagnose`,
322
+ `autotel query` and the MCP tools read what the viewer shows:
323
+
324
+ ```bash
325
+ autotel diagnose errors --backend devtools --devtools-base-url http://localhost:4318
326
+ ```
205
327
 
206
328
  ## Configuration
207
329
 
208
330
  ### Environment Variables
209
331
 
210
332
  ```bash
211
- AUTOTEL_MAX_TRACE_COUNT=10000 # Max traces to keep (default: 100)
212
- AUTOTEL_MAX_LOG_COUNT=10000 # Max logs to keep (default: 100)
213
- AUTOTEL_MAX_METRIC_COUNT=50000 # Max metrics to keep (default: 100)
333
+ AUTOTEL_MAX_TRACE_COUNT=10000 # Live-tail buffer size (default: 100)
334
+ AUTOTEL_MAX_LOG_COUNT=10000 # Live-tail buffer size (default: 100)
335
+ AUTOTEL_DEVTOOLS_DB=./tel.db # sqlite file for the store (default: in-memory)
336
+ AUTOTEL_DEVTOOLS_MAX_TRACES=100000 # Traces retained in the store before pruning
214
337
  AUTOTEL_DEVTOOLS_PORT=4318 # Server port (default: 4318)
338
+ AUTOTEL_DEVTOOLS_GRPC_PORT=4317 # OTLP/gRPC port (default: 4317)
339
+ AUTOTEL_DEVTOOLS_DB_MAX_SIZE=2gb # Logical sqlite retention cap
215
340
  AUTOTEL_DEVTOOLS_HOST=127.0.0.1 # Bind host (default: 127.0.0.1)
216
341
  AUTOTEL_DEVTOOLS_TITLE="My App" # Dashboard title (optional)
217
342
  AUTOTEL_DEVTOOLS_SOURCE_ROOT=. # Root GET /source may read (default: cwd on a loopback bind, else off; `false` disables)
@@ -222,6 +347,7 @@ AUTOTEL_DEVTOOLS_SOURCE_ROOT=. # Root GET /source may read (default: cwd on a
222
347
  ```bash
223
348
  npx autotel-devtools 4319 # port as a bare positional
224
349
  npx autotel-devtools --port 4319 --host 0.0.0.0
350
+ npx autotel-devtools --db ./telemetry.db # keep telemetry across restarts
225
351
  npx autotel-devtools claude # receiver + launch Claude Code wired to it
226
352
  npx autotel-devtools claude --print-env # print the telemetry env, don't launch
227
353
  ```
@@ -239,17 +365,22 @@ Options:
239
365
  - `--port, -p` - Port to listen on (default: 4318). If the port is taken, the receiver walks forward to the next free port and prints a warning.
240
366
  - `--host, -H` - Host to bind to (default: 127.0.0.1)
241
367
  - `--title, -t` - Dashboard title, used for the startup banner and the browser tab (default: `autotel-devtools`). Useful when several dashboards are open at once.
368
+ - `--db, -d` - sqlite file backing the store, so telemetry survives a restart (default: in-memory, which keeps everything until the process exits)
369
+ - `--max-traces` - Traces retained in the store before the oldest are pruned (default: 100000). Pruning runs every 30s.
370
+ - `--grpc-port` - OTLP/gRPC port (default: 4317). If busy, the receiver reports the fallback port.
371
+ - `--db-max-size` - Logical sqlite retention cap (`512mb`, `2gb`, etc.). Defaults to 512 MiB in memory and 2 GiB on disk.
242
372
 
243
373
  When bound to a loopback host, the receiver listens on **both** `127.0.0.1`
244
374
  and `::1`, so a client connecting via `localhost` reaches it regardless of how
245
375
  the OS resolves `localhost` (macOS prefers IPv6 `::1`). The startup banner
246
- prints every address it bound; if a family can't be bound you get a warning,
247
- not a silent black hole.
376
+ prints every address it bound, and warns when it cannot bind a family rather
377
+ than failing silently. Both listeners serve the HTTP routes _and_ the `/ws`
378
+ live tail, so the widget streams whichever form of `localhost` it resolved.
248
379
 
249
380
  ## Behind a dev-server proxy
250
381
 
251
- If your app's dev server proxies `/v1/traces` to the receiver, two classic
252
- bugs make spans vanish with **no error**. Both worth knowing:
382
+ If your app's dev server proxies `/v1/traces` to the receiver, two bugs make
383
+ spans vanish with **no error**:
253
384
 
254
385
  ```ts
255
386
  // Express / http-proxy-middleware
@@ -270,15 +401,15 @@ app.use(
270
401
  ```
271
402
 
272
403
  > Symptom of either bug: the browser shows the request leaving (200/no error),
273
- > but the receiver stays empty. Always verify on the **receiver** side (below),
274
- > not just that the browser tried to send.
404
+ > but the receiver stays empty. Always verify on the **receiver** side (below).
405
+ > The browser trying to send proves nothing.
275
406
 
276
407
  ## Verifying ingestion in tests
277
408
 
278
409
  The receiver exposes an HTTP read-back so a test can assert the collector
279
- **actually received** spans. Instead of asserting "the client tried to send",
280
- which a browser-level route intercept can fake (it fulfils the request before
281
- it reaches any server):
410
+ **received** spans, rather than that the client tried to send them. A
411
+ browser-level route intercept can fake the second one by fulfilling the request
412
+ before it reaches any server:
282
413
 
283
414
  ```bash
284
415
  GET /v1/traces # → { traces: [...], count: N } what the receiver has
@@ -286,7 +417,7 @@ DELETE /v1/traces # clear captured telemetry (reset between tests)
286
417
  ```
287
418
 
288
419
  ```ts
289
- // Playwright / integration test — bypass any page.route() intercept and ask
420
+ // Playwright / integration test: bypass any page.route() intercept and ask
290
421
  // the collector directly.
291
422
  await fetch(`${RECEIVER}/v1/traces`, { method: 'DELETE' }); // reset
292
423
  await runTheUserFlow(); // app emits spans
@@ -295,8 +426,32 @@ await expect
295
426
  .toBeGreaterThan(0);
296
427
  ```
297
428
 
298
- These read-back calls run from Node (no `Origin` header), so they are unaffected
299
- by the origin guard below.
429
+ These read-back calls run from Node with no `Origin` header, so the origin guard
430
+ below leaves them alone.
431
+
432
+ ## Trace payload shape
433
+
434
+ The server **answers in full and streams compact**. Every HTTP response carries
435
+ a complete trace, so `GET /v1/traces`, `POST /api/query/traces` and anything
436
+ else you can `curl` need no decoding.
437
+
438
+ The `/ws` stream is the one exception. It leaves off any span `endTime` that
439
+ `startTime + duration` already reproduces, which is 32% of the compressed
440
+ payload on a large trace. Writing your own WebSocket client? Rehydrate in one
441
+ call:
442
+
443
+ ```ts
444
+ import { decodeTraces } from 'autotel-devtools/wire';
445
+
446
+ ws.addEventListener('message', (event) => {
447
+ const data = JSON.parse(event.data);
448
+ const traces = decodeTraces(data.traces ?? []);
449
+ });
450
+ ```
451
+
452
+ Responses over 1 KiB are gzipped when the client accepts it, and the WebSocket
453
+ negotiates `permessage-deflate`. Browser `fetch`, browser `WebSocket` and
454
+ undici all handle that transparently, so this is normally invisible.
300
455
 
301
456
  ## Read-surface origin guard
302
457
 
package/dist/cli.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- const require_http = require('./http-CXSzX4ee.cjs');
3
- const require_listen = require('./listen-CEJ3nYJf.cjs');
2
+ const require_http = require('./http-Cqm_MAtc.cjs');
3
+ const require_listen = require('./listen-l09RRHht.cjs');
4
+ const require_grpc = require('./grpc-CSWjMRiB.cjs');
4
5
  let node_http = require("node:http");
5
6
  let node_fs = require("node:fs");
6
7
  let node_path = require("node:path");
@@ -9,7 +10,7 @@ let node_child_process = require("node:child_process");
9
10
 
10
11
  //#region src/cli.ts
11
12
  function printHelp() {
12
- process.stdout.write("autotel-devtools - Standalone OTLP receiver with web devtools UI\n\nUsage: autotel-devtools [port] [options]\n autotel-devtools claude [claude args] [--print-env] [--log-prompts]\n\nSubcommands:\n claude Start the receiver AND launch Claude Code wired to it\n (HTTP/protobuf → this receiver, 1s intervals, session id on).\n Open the \"Agents\" tab to watch sessions, tokens, cost,\n tool / MCP / sub-agent / skill usage live.\n --print-env Print the telemetry env block and exit (don't launch)\n --log-prompts Capture prompt text (default: length only / private)\n\nArguments:\n port Port to listen on (shorthand for --port; must be a positive integer)\n\nOptions:\n -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).\n If the port is taken, the next free port is used and a warning is shown.\n -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)\n -t, --title <title> Dashboard title, shown in the banner and browser tab\n (env: AUTOTEL_DEVTOOLS_TITLE)\n Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT, AUTOTEL_MAX_METRIC_COUNT\n -h, --help Show this help message\n -v, --version Show version number\n\nEndpoints:\n GET / Web devtools UI (fullpage)\n GET /widget.js Widget bundle (embed in your app)\n POST /v1/traces Receive OTLP JSON trace data\n GET /v1/traces Read back received traces (verify ingestion in tests)\n DELETE /v1/traces Clear captured telemetry (test reset)\n POST /v1/logs Receive OTLP JSON log data\n POST /v1/metrics Receive OTLP JSON metric data\n WS /ws WebSocket stream for real-time updates\n GET /healthz Health check\n\nExamples:\n npx autotel-devtools\n npx autotel-devtools 4319\n npx autotel-devtools -p 4319 -H 0.0.0.0\n npx autotel-devtools claude # watch Claude Code in the Agents tab\n npx autotel-devtools claude --print-env # just print the env (for MDM / VS Code)\n\nThen point your app:\n OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js\n\nView in browser:\n http://localhost:4318\n\nOr embed widget in your app:\n <script src=\"http://localhost:4318/widget.js\"><\/script>\n\n");
13
+ process.stdout.write("autotel-devtools - Standalone OTLP receiver with web devtools UI\n\nUsage: autotel-devtools [port] [options]\n autotel-devtools claude [claude args] [--print-env] [--log-prompts]\n\nSubcommands:\n claude Start the receiver AND launch Claude Code wired to it\n (HTTP/protobuf → this receiver, 1s intervals, session id on).\n Open the \"Agents\" tab to watch sessions, tokens, cost,\n tool / MCP / sub-agent / skill usage live.\n --print-env Print the telemetry env block and exit (don't launch)\n --log-prompts Capture prompt text (default: length only / private)\n\nArguments:\n port Port to listen on (shorthand for --port; must be a positive integer)\n\nOptions:\n -p, --port <port> Port to listen on (default: 4318, env: AUTOTEL_DEVTOOLS_PORT).\n If the port is taken, the next free port is used and a warning is shown.\n -H, --host <host> Host to bind to (default: 127.0.0.1, env: AUTOTEL_DEVTOOLS_HOST)\n -t, --title <title> Dashboard title, shown in the banner and browser tab\n -d, --db <path> Keep telemetry in a sqlite file so it survives restarts\n (default: in-memory, env: AUTOTEL_DEVTOOLS_DB)\n --max-traces <n> Traces retained before the oldest are pruned\n (default: 100000, env: AUTOTEL_DEVTOOLS_MAX_TRACES)\n --grpc-port <n> OTLP/gRPC port (default: 4317, env: AUTOTEL_DEVTOOLS_GRPC_PORT)\n --db-max-size <size> Logical store cap, e.g. 512mb or 2gb\n (default: 512mb memory / 2gb disk, env: AUTOTEL_DEVTOOLS_DB_MAX_SIZE)\n (env: AUTOTEL_DEVTOOLS_TITLE)\n Env limits: AUTOTEL_MAX_TRACE_COUNT, AUTOTEL_MAX_LOG_COUNT\n -h, --help Show this help message\n -v, --version Show version number\n\nEndpoints:\n GET / Web devtools UI (fullpage)\n GET /widget.js Widget bundle (embed in your app)\n POST /v1/traces Receive OTLP JSON trace data\n GET /v1/traces Read back received traces (verify ingestion in tests)\n DELETE /v1/traces Clear captured telemetry (test reset)\n POST /v1/logs Receive OTLP JSON log data\n POST /v1/metrics Receive OTLP JSON metric data\n gRPC :4317 Receive OTLP protobuf traces, logs and metrics\n WS /ws WebSocket stream for real-time updates\n GET /healthz Health check\n\nExamples:\n npx autotel-devtools\n npx autotel-devtools 4319\n npx autotel-devtools -p 4319 -H 0.0.0.0\n npx autotel-devtools claude # watch Claude Code in the Agents tab\n npx autotel-devtools claude --print-env # just print the env (for MDM / VS Code)\n\nThen point your app:\n OTEL_EXPORTER_OTLP_PROTOCOL=http/json OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 node app.js\n\nView in browser:\n http://localhost:4318\n\nOr embed widget in your app:\n <script src=\"http://localhost:4318/widget.js\"><\/script>\n\n");
13
14
  }
14
15
  function printVersion() {
15
16
  try {
@@ -24,7 +25,11 @@ function parseArgs(argv) {
24
25
  const options = {
25
26
  port: parsePort(process.env.AUTOTEL_DEVTOOLS_PORT || "4318"),
26
27
  host: process.env.AUTOTEL_DEVTOOLS_HOST || "127.0.0.1",
27
- title: process.env.AUTOTEL_DEVTOOLS_TITLE
28
+ title: process.env.AUTOTEL_DEVTOOLS_TITLE,
29
+ dbPath: process.env.AUTOTEL_DEVTOOLS_DB,
30
+ maxTraces: parseCount(process.env.AUTOTEL_DEVTOOLS_MAX_TRACES),
31
+ maxDbBytes: parseBytes(process.env.AUTOTEL_DEVTOOLS_DB_MAX_SIZE),
32
+ grpcPort: parsePort(process.env.AUTOTEL_DEVTOOLS_GRPC_PORT || "4317")
28
33
  };
29
34
  let portWasExplicit = false;
30
35
  let positionalPortConsumed = false;
@@ -55,6 +60,26 @@ function parseArgs(argv) {
55
60
  i++;
56
61
  continue;
57
62
  }
63
+ if ((arg === "--db" || arg === "-d") && next) {
64
+ options.dbPath = next;
65
+ i++;
66
+ continue;
67
+ }
68
+ if (arg === "--max-traces" && next) {
69
+ options.maxTraces = parseCount(next);
70
+ i++;
71
+ continue;
72
+ }
73
+ if (arg === "--grpc-port" && next) {
74
+ options.grpcPort = parsePort(next);
75
+ i++;
76
+ continue;
77
+ }
78
+ if (arg === "--db-max-size" && next) {
79
+ options.maxDbBytes = parseBytes(next);
80
+ i++;
81
+ continue;
82
+ }
58
83
  if (/^\d+$/.test(arg) && !positionalPortConsumed) {
59
84
  if (!portWasExplicit) options.port = parsePort(arg);
60
85
  positionalPortConsumed = true;
@@ -63,6 +88,28 @@ function parseArgs(argv) {
63
88
  }
64
89
  return options;
65
90
  }
91
+ /**
92
+ * A positive integer option, or undefined when unset.
93
+ *
94
+ * Unlike `parsePort` this does not exit on a bad value: a nonsense retention
95
+ * cap should fall back to the default rather than refuse to start a viewer.
96
+ */
97
+ function parseCount(value) {
98
+ if (!value) return void 0;
99
+ const n = Number(value);
100
+ return Number.isInteger(n) && n > 0 ? n : void 0;
101
+ }
102
+ function parseBytes(value) {
103
+ if (!value) return void 0;
104
+ const match = value.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
105
+ if (!match) return void 0;
106
+ return Math.floor(Number(match[1]) * {
107
+ b: 1,
108
+ kb: 1024,
109
+ mb: 1024 ** 2,
110
+ gb: 1024 ** 3
111
+ }[match[2] || "b"]);
112
+ }
66
113
  function parsePort(value) {
67
114
  const n = Number(value);
68
115
  if (!Number.isInteger(n) || n < 0 || n > 65535) {
@@ -77,7 +124,10 @@ async function startReceiver(options) {
77
124
  const wsServer = new require_http.DevtoolsServer({
78
125
  server: httpServer,
79
126
  host: options.host,
80
- verbose: true
127
+ verbose: true,
128
+ dbPath: options.dbPath,
129
+ maxTraces: options.maxTraces,
130
+ maxDbBytes: options.maxDbBytes
81
131
  });
82
132
  const sourceRoot = require_http.resolveSourceRoot(process.env.AUTOTEL_DEVTOOLS_SOURCE_ROOT, process.cwd(), loopbackOnly);
83
133
  require_http.attachDevtoolsRoutes(httpServer, wsServer, {
@@ -89,22 +139,36 @@ async function startReceiver(options) {
89
139
  primary: httpServer,
90
140
  port: options.port,
91
141
  host: options.host,
92
- attachSecondary: (s) => require_http.attachDevtoolsRoutes(s, wsServer, {
93
- loopbackOnly,
94
- title: options.title,
95
- sourceRoot
96
- })
142
+ attachSecondary: (s) => {
143
+ require_http.attachDevtoolsRoutes(s, wsServer, {
144
+ loopbackOnly,
145
+ title: options.title,
146
+ sourceRoot
147
+ });
148
+ wsServer.attachWebSocket(s);
149
+ }
97
150
  });
98
151
  const { addresses, warnings, port: boundPort } = await listeners.ready;
152
+ const grpc = await require_grpc.startOtlpGrpcReceiver({
153
+ devtools: wsServer,
154
+ host: options.host,
155
+ port: options.grpcPort
156
+ });
157
+ if (grpc.port !== options.grpcPort) warnings.push(`OTLP/gRPC port ${options.grpcPort} was busy; using ${grpc.port}.`);
99
158
  if (boundPort !== options.port) if (await require_http.probePortHolder(options.host, options.port) === "autotel-devtools") warnings.push(`another autotel-devtools is already running on port ${options.port}; this instance is on ${boundPort}. Use the existing one, or stop it and restart here.`);
100
159
  else warnings.push(`port ${options.port} is held by another process that is NOT autotel-devtools. Anything exporting OTLP to :${options.port} is reaching that process, not this devtools. Point your exporter at :${boundPort}, or free :${options.port} and restart.`);
101
160
  return {
102
161
  wsServer,
103
- closeAll: () => Promise.all([wsServer.close(), listeners.closeSibling()]).then(() => void 0),
162
+ closeAll: () => Promise.all([
163
+ wsServer.close(),
164
+ listeners.closeSibling(),
165
+ grpc.close()
166
+ ]).then(() => void 0),
104
167
  addresses,
105
168
  warnings,
106
169
  boundPort,
107
- uiBase: `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`
170
+ uiBase: `http://${options.host === "localhost" ? "127.0.0.1" : options.host}:${boundPort}`,
171
+ grpcAddress: grpc.address
108
172
  };
109
173
  }
110
174
  function buildAgentEnv(uiBase, logPrompts) {
@@ -169,7 +233,8 @@ async function runClaudeSubcommand(argv) {
169
233
  }
170
234
  const receiver = await startReceiver({
171
235
  port: opts.port,
172
- host: opts.host
236
+ host: opts.host,
237
+ grpcPort: 4317
173
238
  });
174
239
  const env = buildAgentEnv(receiver.uiBase, opts.logPrompts);
175
240
  process.stdout.write(`\n autotel-devtools — Claude Code\n\n`);
@@ -214,6 +279,7 @@ async function main() {
214
279
  process.stdout.write(` Listening: ${addresses.join(" + ")}\n`);
215
280
  process.stdout.write(` UI: ${uiBase} (open in a browser)\n`);
216
281
  process.stdout.write(` OTLP: ${uiBase}/v1/traces\n`);
282
+ process.stdout.write(` OTLP/gRPC: ${receiver.grpcAddress}\n`);
217
283
  process.stdout.write(` WebSocket: ${uiBase.replace("http", "ws")}/ws\n\n`);
218
284
  process.stdout.write(` Embed in your app — paste into your HTML; a floating panel appears automatically:\n`);
219
285
  process.stdout.write(` <script src="${uiBase}/widget.js"><\/script>\n\n`);
@@ -222,6 +288,7 @@ async function main() {
222
288
  process.stdout.write(` Or point any OTLP exporter at this receiver:\n`);
223
289
  process.stdout.write(` OTEL_EXPORTER_OTLP_PROTOCOL=http/json\n`);
224
290
  process.stdout.write(` OTEL_EXPORTER_OTLP_ENDPOINT=${uiBase}\n\n`);
291
+ process.stdout.write(` Or use gRPC: OTEL_EXPORTER_OTLP_ENDPOINT=http://${receiver.grpcAddress}\n\n`);
225
292
  process.stdout.write(` Verify ingestion: curl -s ${uiBase}/v1/traces\n\n`);
226
293
  for (const w of warnings) process.stdout.write(` ⚠ ${w}\n`);
227
294
  if (warnings.length > 0) process.stdout.write("\n");