Skip to content

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.

Terminal window
npm install autotel-devtools

The package ships a CLI binary, a Node.js library, and a browser widget bundle.

Run the receiver, then point any OTLP exporter at it:

Terminal window
npx autotel-devtools
Terminal window
OTEL_EXPORTER_OTLP_PROTOCOL=http/json \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
node app.js

Open 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. 512mb or 2gb (default: 512mb in memory, 2gb on 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

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.

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.

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
});

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.

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:

Terminal window
npx autotel-devtools claude

That 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.

Terminal window
npx autotel-devtools claude --print-env # print the env block, launch nothing
npx 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.

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.

Every list view sits on a query language with a tokenizer, a parser and a SQL compiler:

service = api AND duration > 100
name CONTAINS checkout
http.status_code = 500
service IN [api, web]
name REGEXP "^GET "
parent = NULL

Any 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.

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.

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 answers “what is different about the ones that broke”. Give it two queries, and it ranks the attributes that separate the traces they match:

  1. Query the traces you are investigating, for example duration > 500.
  2. Leave the second query empty to compare against every trace in the window, or write one to pick your own baseline.
  3. 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.

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.

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.

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.

The LGTM stack binds 4318 for OTLP HTTP, the same default devtools uses. Move devtools to run both:

Terminal window
AUTOTEL_DEVTOOLS_PORT=4319 npx autotel-devtools

Then 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.

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.

Apache-2.0.