Devtools
autotel-devtools is a local OpenTelemetry receiver with a Svelte 5 web UI.
Think TanStack Devtools for OTLP. Run it as a CLI to inspect traces, logs,
metrics, and errors in the browser, or embed the widget directly in your app
for in-page diagnostics.
Any OTLP-compatible exporter sends into it, so autotel and vanilla OpenTelemetry both work.
Installation
Section titled “Installation”npm install autotel-devtoolsThe package ships a CLI binary, a Node.js library, and a browser widget bundle.
Standalone Mode
Section titled “Standalone Mode”Run the receiver, then point any OTLP exporter at it:
npx autotel-devtoolsOTEL_EXPORTER_OTLP_PROTOCOL=http/json \OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \node app.jsOpen http://localhost:4318 for the dashboard. It
includes a traces waterfall, flame graph, log filtering by severity and
resource, error aggregation with clickable stack frames
(see below), service map, resources view, a GenAI view for
LLM and agent runs (see below), and a Security lens that
surfaces spans carrying the security.* schema (see
Security Observability). Search debounces at
300ms.
CLI options:
-d, --db <path>: Keep telemetry in a sqlite file so it survives restarts (default: in-memory)--max-traces <n>: Traces retained before the oldest are pruned (default:100000)--db-max-size <size>: Logical store cap, e.g.512mbor2gb(default:512mbin memory,2gbon disk)--grpc-port <n>: OTLP/gRPC port (default:4317)-p, --port <port>: Port (default:4318). If the port is taken, falls forward to the next free port and prints a warning. When the original port is held by a foreign process (not another autotel-devtools), the warning explains that any OTLP exporter still pointed at the busy port is reaching that process. Point the exporter at the bound port, or free the original and restart.-H, --host <host>: Host (default:127.0.0.1)-t, --title <title>: Dashboard title
Embedded Widget
Section titled “Embedded Widget”Add the widget to any web page. It mounts as a custom element with Shadow DOM isolation, so its CSS never leaks into your app:
<script src="http://localhost:4318/widget.js"></script><autotel-devtools></autotel-devtools>The widget connects to the receiver over WebSocket, replays history on connect,
and persists position via localStorage.
Programmatic API
Section titled “Programmatic API”For tighter integration, wire the exporter directly into init() so spans flow
to devtools without going over HTTP:
import { init, trace, withTracing } from 'autotel';import { createDevtools } from 'autotel-devtools';
const { exporter, close } = createDevtools({ port: 4318, verbose: true,});
init({ service: 'my-app', endpoint: 'http://localhost:4318', spanProcessors: [exporter],});
const checkout = withTracing({})((ctx) => async (req, res) => { // spans appear in the devtools UI in real time});Call close() on shutdown to release the port and WebSocket clients.
Errors and source peek
Section titled “Errors and source peek”The Errors tab groups identical failures and renders each group’s stack as a
list of frames rather than a wall of text. Frames are labelled by where they come
from: your code, node_modules, or runtime (Node internals such as node:fs).
Only your own frames are clickable, because they are the only ones with a file in
the project. Click one and the lines around the failure appear underneath, with
the real line numbers. The raw stack is still there under Raw stack, with the
copy button, when you want to paste the whole thing somewhere.
Stacks are read from exception.stacktrace, exception.stack, an exception
span event, or error.stack. The last is what autotel’s
structured errors write, so those
now show frames too.
Source comes from AUTOTEL_DEVTOOLS_SOURCE_ROOT, which defaults to the
directory the receiver started in. Reads are confined to that directory —
symlinks are resolved, so one pointing outside the project is refused — and
GET /source sits behind the same loopback/Origin guard as the other read
endpoints. Set AUTOTEL_DEVTOOLS_SOURCE_ROOT=false to turn it off; the route
then 404s and devtools never touches the filesystem. A frame with no readable
file just says so instead of failing.
Only on a loopback bind. With --host 0.0.0.0 the default flips to off. The
Origin guard alone does not carry this route: a request with no Origin header
at all — any curl on the network — passes it, and the root holds whatever else
lives in your project, .env included. Set AUTOTEL_DEVTOOLS_SOURCE_ROOT
explicitly if you want it there anyway.
createDevtools() follows the same rule, and takes a
sourceRoot option (false to opt out):
const { exporter, close } = createDevtools({ port: 4318, sourceRoot: process.cwd(), // default on a loopback bind; `false` disables});GenAI runs
Section titled “GenAI runs”When your app emits OpenTelemetry GenAI spans. For example spans with
gen_ai.provider.name and gen_ai.operation.name. The GenAI tab turns
them into a readable view of each LLM and agent run. It works with anything that
follows the spec, including the Vercel AI SDK via
registerTelemetry(autotelTelemetry()) or subscribeAiTelemetry(),
Pydantic AI + Logfire, OpenAI Agents, Anthropic, Google GenAI, LangChain and
OpenLLMetry.
Per span you get the model, provider, parameters, token usage (with the cached and reasoning share called out), estimated cost, and the full conversation transcript. Messages, tool calls and their results.
On top of the per-span detail, three things make a multi-span run readable at a glance:
- Run summary strip: a KPI row above the detail showing the run’s total
cost, input→output tokens, reasoning tokens, model calls, tool executions,
sub-agents, duration and errors. Cost is summed only from priced calls and
marked with a trailing
+when some calls are unpriced (a lower bound, never a fabricated total). Wrapper/aggregate spans and tool calls replayed across turns are de-duplicated, so counts and totals don’t double-count. - Explain run: a guided, narrated walkthrough for demos. It steps through the run in order with plain-language narration of each step (“the model decides what to do”, “a tool is real code the agent ran”, “the model writes the answer”). Auto-play, step with the arrow keys / Space, Esc to exit; clicking a span jumps the tour to that step.
- Trace: decomposes the selected run into a depth-indented tree of what happened inside it: each model call broken into its reasoning, the tools it called (with arguments and results) and the text it wrote, with nested sub-agents underneath. A Timeline view shows the same run as time-aligned lanes.
Coding agents
Section titled “Coding agents”Claude Code and opencode emit no traces. They emit OpenTelemetry metrics and log
events: api_request, tool_result, tool_decision, user_prompt, plus token
and cost counters. The Agents tab reassembles those into one session per run.
One command starts the receiver and launches Claude Code wired to it:
npx autotel-devtools claudeThat sets OTLP http/protobuf against this receiver, a 1s export interval, and
keeps session.id on metrics. Most Claude Code telemetry guides configure gRPC,
which this receiver does not speak, so use this command rather than copying an
env block from elsewhere.
npx autotel-devtools claude --print-env # print the env block, launch nothingnpx autotel-devtools claude --log-prompts # capture prompt text (default: length only)Use --print-env when the env belongs somewhere else, such as managed settings,
MDM, or a VS Code profile.
Per session you get a timeline (prompts, tool calls, API requests, accept and
reject decisions), a rollup (cost, tokens, requests, lines changed), and
breakdowns by tool category, MCP server, sub-agent, and skill. Claude Code names
MCP tools mcp__<server>__<tool>, so usage splits by server without any extra
configuration. Cost uses the agent’s reported cost_usd and falls back to a
badged token estimate.
The session model lives in autotel-agents, a browser-safe package with no I/O.
The devtools server decodes OTLP and feeds it plain objects:
import { ingestEventRecord, ingestMetricRecord, summarizeSessions,} from 'autotel-agents';import type { AgentSessionStore } from 'autotel-agents';
const store: AgentSessionStore = new Map();
ingestEventRecord(store, decodedLogRecord);ingestMetricRecord(store, decodedMetric);
const aggregate = summarizeSessions([...store.values()]);Events are authoritative for the timeline and for cost and token totals. Metrics
fill the gaps events do not cover, such as lines of code and commits. Token and
cost metrics get recognised and never summed, because they overlap api_request
events and summing both would double-count.
For what happens inside the MCP protocol itself, see MCP. This tab covers the agent, not the wire.
Configuration
Section titled “Configuration”| Variable | Default | Description |
|---|---|---|
AUTOTEL_DEVTOOLS_PORT |
4318 |
Server port |
AUTOTEL_DEVTOOLS_HOST |
127.0.0.1 |
Bind host |
AUTOTEL_DEVTOOLS_TITLE |
unset | Dashboard title |
AUTOTEL_DEVTOOLS_SOURCE_ROOT |
cwd on a loopback bind, else off | Root GET /source may read; false disables |
AUTOTEL_DEVTOOLS_DB |
unset (in-memory) | sqlite file the store writes to |
AUTOTEL_DEVTOOLS_MAX_TRACES |
100000 |
Traces retained before the oldest are pruned |
AUTOTEL_DEVTOOLS_DB_MAX_SIZE |
512mb memory, 2gb disk |
Logical store cap |
AUTOTEL_DEVTOOLS_GRPC_PORT |
4317 |
OTLP/gRPC port |
AUTOTEL_MAX_TRACE_COUNT |
100 |
Traces kept in the live tail |
AUTOTEL_MAX_LOG_COUNT |
100 |
Log records kept in the live tail |
The live-tail limits govern what the widget replays on connect. Retention in the store is separate and much larger, so a query reaches further back than the tail does.
Querying
Section titled “Querying”Every list view sits on a query language with a tokenizer, a parser and a SQL compiler:
service = api AND duration > 100name CONTAINS checkouthttp.status_code = 500service IN [api, web]name REGEXP "^GET "parent = NULLAny field that is not a declared column is looked up as a span attribute, so everything a service emits is queryable without being declared anywhere. Values always become bound parameters and identifiers always come from a schema, so no text you type reaches the SQL string.
A bare word with no operator is free-text search. A malformed query comes back
as a 400 with the position of the problem rather than a 500.
autotel-devtools/query exports parse, compileWhere and the operator table,
so a tool that generates queries can check them against the real grammar instead
of a copy of it.
Persistence
Section titled “Persistence”const { exporter, close } = createDevtools({ dbPath: './telemetry.db', maxTraces: 100_000,});Both default to in-memory, so an existing embedder keeps its current behaviour
and gains querying and paging. The store is node:sqlite, which is in the
standard library on Node 24 and costs no new dependency.
Time window
Section titled “Time window”One window applies across tabs and serialises into the URL. Presets are stored as intents, so “Last 15m” keeps tracking now instead of freezing at the moment you clicked it. A deep link to a trace carries that trace’s own bounds plus a minute of air, so a link you hand to someone opens on a window that still contains the trace.
Compare two cohorts
Section titled “Compare two cohorts”Compare answers “what is different about the ones that broke”. Give it two queries, and it ranks the attributes that separate the traces they match:
- Query the traces you are investigating, for example
duration > 500. - Leave the second query empty to compare against every trace in the window, or write one to pick your own baseline.
- Read the ranked fields. A field near the top appears in the first cohort far more often than in the second.
Both cohorts obey the time window in the toolbar, so a comparison covers the period you are looking at rather than everything the store has kept.
When you cannot describe the difference as a query, mark the moment instead. Mark, change something, then compare. Both sides then run the second query and split on the marker, so what differs is your change and not the filter.
When a retained span carries experiment.name, Compare offers an experiment
picker above the queries. Pick one and you get its own arms, taken from the
spans that ran under it, with the two commonest filled in for you. Change either
side: an experiment with three arms has a comparison the counts cannot guess.
Leaving the second side on “every other arm” compares the one you are
investigating against the rest of the experiment, and the arm under
investigation is never offered as its own baseline. The generated queries name
the experiment as well as the variant, so they stay correct where two
experiments share a variant label. experiment.name and experiment.variant
are then left out of the ranking: they define the two cohorts, so they separate
them perfectly and tell you nothing.
Stamp those attributes with
experiment() from
autotel. A viewer that has never seen an experiment never sees the picker.
Compare borrows compareCohorts from autotel/analysis, which is a peer
dependency. Without autotel installed the endpoint answers 501 with an
install hint rather than failing to start. Compare and Coverage are full-page
only; the embedded widget stays lean.
The ranking is a hypothesis. Open two traces from the named cohort and confirm the mechanism before you act on it.
Endpoints
Section titled “Endpoints”The standalone server exposes:
| Path | Method | Purpose |
|---|---|---|
/v1/traces |
POST | OTLP/HTTP traces ingest (JSON or protobuf) |
/v1/traces |
GET | Read back received traces |
/v1/traces |
DELETE | Clear all captured telemetry |
/v1/logs |
POST | OTLP/HTTP logs ingest (JSON or protobuf) |
/v1/metrics |
POST | OTLP/HTTP metrics ingest (JSON or protobuf) |
/api/query/traces |
POST | Run a query against stored traces |
/api/query/logs |
POST | Run a query against stored logs |
/api/query/errors |
POST | Error groups over stored traces |
/api/query/metrics |
POST | Metric series for the chosen window |
/api/query/attributes |
GET | Fields holding a value, or one field’s values paired |
/api/analysis/compare |
POST | Compare two cohorts of traces |
/api/coverage |
GET | Entry points from autotel map against what arrived |
/ |
GET | Full-page dashboard UI |
/widget.js |
GET | Embeddable widget bundle |
/healthz |
GET | Health + identity ({ ok, service, version, clients }) |
/source |
GET | Source lines around a stack frame (loopback bind only) |
/ws |
WS | WebSocket stream (live updates + replay) |
Every response carries an x-autotel-devtools: <version> header. Use it to
confirm you are talking to autotel-devtools rather than another OTLP collector
that happens to share the port.
Read surface is origin-guarded. Ingestion (POST), /widget.js and
/healthz are open to any origin so apps on arbitrary dev origins can send
telemetry and load the widget. The read/clear endpoints (GET/DELETE /v1/traces), GET /source and the /ws stream reject cross-origin browser requests (a
non-loopback Origin) with 403, so a page you happen to be visiting can’t read
your captured prompts and responses. When bound to a loopback host (the default),
a non-loopback Host is rejected too (DNS-rebinding defense); --host 0.0.0.0
opts into network exposure and applies only the Origin check. Server-side reads
with no Origin (curl, Node fetch in tests) are unaffected.
Identity Detection
Section titled “Identity Detection”When an IDE or other tool is already listening on port 4318, the CLI warns you and falls forward to the next free port. To detect what owns a port programmatically:
import { probePortHolder } from 'autotel-devtools/server';
// Returns 'autotel-devtools' | 'foreign' | 'none'const holder = await probePortHolder('127.0.0.1', 4318);probePortHolder checks for the x-autotel-devtools header and the
/healthz service field. Use it in test setup scripts to verify the
receiver is ready, or in tooling that needs to decide whether to start a new
instance.
Use with autotel-mcp
Section titled “Use with autotel-mcp”autotel-mcp also binds port 4318 by default,
because it starts its own OTLP collector. Rather than run two collectors, let
the agent read through devtools: start devtools on 4318 and run the MCP server
with AUTOTEL_BACKEND=devtools. One receiver owns the port, the dashboard and
the agent read the same traces. The
Run alongside autotel-devtools
recipe has the full setup and the traces-only caveat.
Use with a local stack
Section titled “Use with a local stack”The LGTM stack binds 4318 for OTLP HTTP, the same default devtools uses. Move devtools to run both:
AUTOTEL_DEVTOOLS_PORT=4319 npx autotel-devtoolsThen send to whichever you want to read from: devtools at
http://127.0.0.1:4319 for a live view of the request you just made, LGTM at
http://localhost:4318 for history and Grafana queries.
The Jaeger stack has no such clash — it only takes the OTLP ports, so devtools keeps 4318 whenever Jaeger is down.
Devtools vs. Terminal Viewer
Section titled “Devtools vs. Terminal Viewer”Both ship as local OTLP receivers. Pick by surface:
- Devtools. Browser UI, embeddable widget, service map, flame graph, longer scrollback. Best when you want a real dashboard alongside the running app.
- Terminal viewer. Ink-powered TUI, no browser needed. Best when you live in the terminal or are running over SSH.
To skip the standalone process entirely, enable devtools directly from
init():
init({ service: 'my-app', devtools: true });This wires the exporter automatically. No separate CLI process required.
License
Section titled “License”Apache-2.0.