OTEL viewer for local development.
Local OpenTelemetry viewer inspired by Honeycomb — named for the
waggle dance bees
use to share locations. Run it next to your service, point any OTLP
exporter at http://localhost:4318 (HTTP) or
localhost:4317 (gRPC), and browse a Honeycomb-style
trace waterfall, log explorer, metrics browser, and structured query
builder in the same tab.
- Single static binary — pure Go, no CGO, no Docker required, no Node at runtime.
- OTLP/HTTP ingest (protobuf + JSON) on
POST /v1/traces,POST /v1/logs, andPOST /v1/metrics(port 4318), plus OTLP/gRPC on port 4317. Both transports share the same writer pipeline. - Wide-event storage. All signals land in two SQLite tables —
events(spans + logs, with virtual columns forsignal_type,span_kind, etc.) andmetric_events(Honeycomb-style: the metric's name is an attribute field, soMAX(requests.total)resolves with plain SQL). WAL mode, FTS5 for log/span-name search. - One unified explore page driven by a Honeycomb-style query builder. Dataset (spans / logs / metrics) is a pill in the URL, so the same filters, group-by, and aggregations apply across signals.
- Per-chart controls. Multi-
SELECTqueries render one chart per aggregation, each with its own Edit-chart popover (missing-values handling) and SI-suffixed y-axis. - Embedded React UI served from the same port.
- MCP server for agents. A read-only Model Context Protocol
endpoint at
/mcp(plus awaggle mcpstdio mode) exposes the same query engine as tools, so Claude and other MCP clients can explore your traces, metrics, and logs. See Query from an AI agent.
The /traces Explore-Data tab — root spans for the last hour, filtered
to is_root = true. Each row carries a SIGNAL pill, service name,
operation name, duration, status, and a trace-id link that opens the
waterfall. The dataset pill at the top-left switches between
spans, logs, and metrics.
The /metrics view with a multi-aggregation query: MAX(memory.used_bytes),
AVG(cpu.utilization), RATE_AVG(network.bytes_received) grouped by
service.name. Each SELECT item gets its own stacked chart with an
independent y-axis (note SI-suffixed labels — 1.4G, 600m, 1.2M)
and a per-chart Edit popover for missing-values handling. The Overview
tab below rolls each aggregation up into a single column. Metric names
are queried as plain attribute fields — Honeycomb-style metric-name-as-field
storage, no separate metric language to learn.
Trace waterfall with the span detail pane open. The right-hand
attributes panel shows the meta.* namespace (meta.dataset,
meta.signal_type, meta.span_kind, …) alongside user attributes —
metadata waggle stamps at ingest is queryable like any other field.
The /logs Explore-Data tab — FTS5-indexed bodies with severity
badges, service names, and trace-id correlation back to the waterfall.
The logs Tail tab — less-style focused follow view. Query and
chart collapse out of the way so the feed fills the pane. Lines render
in a zerolog-inspired console format: bold severity pill, white body,
cyan key= with logfmt-style values, and bold-red highlights for
common error attributes (error, exception.message, exception.type,
…) on ERROR-level rows. ANSI SGR escapes in log bodies are honoured.
Polls while Following is on; scrolling up pauses follow, Jump resumes.
The Copy button writes the visible buffer to the clipboard as plain
text, ANSI stripped.
Keyboard shortcuts, modelled on less:
| Key | Action |
|---|---|
/ |
Search (highlight matches — body, attribute keys, attribute values) |
& |
Filter (hide non-matching lines; pattern persists when the prompt closes) |
n / N |
Next / previous match |
Enter / Shift+Enter |
Same as n / N while the search prompt is focused |
Esc |
Close prompt (search pattern clears; filter pattern is kept) |
F |
Toggle Follow (resume tailing) |
g / G |
Jump to top / bottom |
Both prompts support smart case (lowercase = insensitive, any uppercase
= sensitive) and a .* toggle for regex. Filter has an extra !
toggle for "show lines that DON'T match". Filter and search compose:
&pay narrows to payment lines, then /declined highlights card
declines within that view.
Homebrew — macOS or Linuxbrew, via the tap:
brew install danielloader/tap/waggle
brew services start waggle # optional: run as a LaunchAgentBinary — grab a release archive from Releases, extract, and run:
./waggleLinux packages — .deb, .rpm, .apk, and Arch .pkg.tar.zst are
attached to each release
(amd64 + arm64). They install the binary to /usr/bin/waggle, drop a
systemd unit at /lib/systemd/system/waggle.service, create a waggle
system user, and start the service on install. State lives under
/var/lib/waggle/. Packages are unsigned, so some installers want an
explicit "allow untrusted" flag:
# Debian / Ubuntu
sudo dpkg -i waggle_*_amd64.deb
# Fedora / RHEL / openSUSE
sudo rpm -i waggle-*.x86_64.rpm # or: sudo dnf install ./waggle-*.x86_64.rpm
# Alpine (unsigned, hence --allow-untrusted)
sudo apk add --allow-untrusted waggle_*_x86_64.apk
# Arch
sudo pacman -U waggle-*-x86_64.pkg.tar.zst
systemctl status waggle # confirm the service startedDocker — images are published to GitHub Container Registry:
docker run --rm -p 4318:4318 -p 4317:4317 -v $(pwd)/data:/data \
ghcr.io/danielloader/waggle:latestFrom source — requires Go 1.26+ and Node 22+:
go tool task build
./bin/wagglego install — headless server only (no embedded UI):
go install github.com/danielloader/waggle/cmd/waggle@latest
waggleThe UI assets are built by Vite and embedded into the binary during the
normal release / go tool task build flow. go install bypasses that
step, so the resulting binary serves the OTLP ingest endpoints and
the /api/* surface, but / returns "UI not built". Handy for
agent-like deployments where only the ingest + API are needed — and
quick to bootstrap without Node. For the browser UI, use a release
archive, Docker, or a full source build.
Once running, open http://localhost:4318 and point any OTLP exporter
at it: HTTP/protobuf or HTTP/JSON on :4318, or gRPC on :4317 (the
OTel SDK default). Set WAGGLE_GRPC_ADDR="" to disable the gRPC
listener if you only want HTTP.
One explore page drives everything. The sidebar has two entries — the explore page and the query history:
/eventsis the single explore surface. A dataset pill in the URL picks which signal to query:dataset=spans— trace list, Traces tab for top-N slowest roots, Explore Data for raw span rows. Clicking a trace-id opens the waterfall.dataset=logs— FTS5-backed text search on log bodies and span names, click-to-drill-into attributes pane.dataset=metrics— the metric's name is an attribute field, soMAX(requests.total)orP99(memory.used_bytes)resolves through the same field picker as any other attribute.
/history— recent queries, deduplicated. Every successful/eventsquery lands in a localquery_historytable keyed by a hash of the AST (time range excluded), so repeats bump a run counter rather than pile up new rows. Clicking an entry rehydrates the URL and re-runs the query against its original time window.
The paths /traces, /logs, and /metrics exist as redirects into
/events with the matching dataset preset — handy as short entry
points or legacy links. The specialised trace-waterfall route
/traces/$traceId is the one place where the UI leaves the unified
page, because the waterfall's two-column layout doesn't fit the
explore chrome.
Every URL serialises the full query state (filters, group-by, aggregates, time range, granularity) so shared links reproduce the view.
--tee mirrors incoming OTLP log records to a second output in a
human-readable format — zerolog-style console by default, plus
logfmt and json. It's a pure passthrough; SQLite is still
authoritative and the UI is unchanged. Handy for two common shapes:
Watch logs in a terminal while you explore traces + metrics in the browser. When a trace in the UI points at a service, you can often just glance at the tailing terminal to see that service's logs scroll past, without context-switching between panes.
# Colourised live feed, filtered to two services, WARN+ only
waggle --tee - \
--tee-service api-gateway,payments \
--tee-severity warnColour is auto by default — on when stdout is a TTY and the format
is console, off otherwise. Use --tee-color always if you're piping
to less -R / ccze, or never to strip escapes unconditionally.
Save logs to disk for later investigation. For long-running local
sessions, or when you need a durable copy the SQLite retention sweep
(--retention 24h by default) won't eventually drop. logfmt is
grep/awk-friendly, json is jq-friendly.
# Rolling log file, logfmt (grep/awk-friendly)
waggle --tee logs/waggle.logfmt --tee-format logfmt
# NDJSON — one record per line, point jq at it
waggle --tee logs/waggle.ndjson --tee-format json
# Paginate a live feed through less with ANSI-aware paging
waggle --tee - --tee-color always | less -ROnly log records are tee'd; spans and metrics go to SQLite as normal. The sink is best-effort — a write failure is logged once and the ingest path keeps going.
Waggle ships a read-only Model Context Protocol
server so an MCP client — Claude Code, Claude Desktop, or anything that
speaks MCP — can explore your stored traces, metrics, and logs. It's the
same query engine the UI uses (internal/query → SQLite) exposed as tools;
the write paths are never bound, so an agent can read but not mutate.
Over HTTP (recommended) — talk to a running waggle. The endpoint is
mounted at /mcp on the UI listener and is on by default (--mcp /
WAGGLE_MCP):
claude mcp add --transport http waggle http://127.0.0.1:4318/mcpOver stdio — point at a database file, no server needed. Handy for
poking at a .db after the fact:
claude mcp add waggle -- waggle mcp --db-path /path/to/waggle.dbTools: query (the full structured query from Query model —
aggregations, filters, group-by, time buckets), list_services,
list_fields, list_field_values, list_span_names, list_traces,
get_trace, search_logs, and recent_queries. Time ranges accept a
relative {"last":"1h"} as well as absolute bounds, and results are capped
per-tool (a truncated result says so) to keep an agent's context bounded.
Full tool reference on the
MCP Server wiki page.
Waggle is service-aware when installed via a package manager — the single-binary download is meant to be run in the foreground.
- macOS (Homebrew formula):
brew services start waggleruns waggle as a LaunchAgent. Logs go to$(brew --prefix)/var/log/waggle.log. Stop withbrew services stop waggle; restart after upgrade withbrew services restart waggle. - Linux (
.deb/.rpm/.apk/ Arch): the package ships/lib/systemd/system/waggle.serviceand creates awagglesystem user. The postinstall enables and starts the service on first install, and restarts it after an upgrade. Manage it with the usualsystemctl status|restart|stop waggle. State lives at/var/lib/waggle/.
The standalone binary, Docker image, and the snap-shot of waggle you build yourself have no service integration — start them however you'd start any other foreground process.
Following Honeycomb's Metrics 2.0 mapping, a metric datapoint is an
event whose attribute keys include the metric's name as a field. One
OTel export cycle per (resource, attribute-set, time_ns) tuple becomes
one metric_events row, with every scalar metric observed at that
moment folded into its attributes JSON. Histograms unpack into
<name>.p50, <name>.p95, <name>.p99, <name>.sum, <name>.count,
<name>.min, <name>.max fields on the same folded row.
Meaning you can:
- Group by a metric label with
group_by: ["http.method"]and chartRATE_SUM(requests.total)per method. - Ask for
MAX(memory.rss_bytes) / 1024 / 1024style arithmetic via the query builder's aggregation pipeline. - Correlate across signals — same
trace_idfilter works on spans and logs simultaneously.
All flags have matching environment variables. Flags take precedence.
| Flag | Env | Default | Notes |
|---|---|---|---|
--db-path |
WAGGLE_DB |
OS user-data dir | SQLite file path. Defaults to $XDG_DATA_HOME/waggle/waggle.db (Linux, falling back to ~/.local/share/waggle/waggle.db), ~/Library/Application Support/waggle/waggle.db (macOS), or %LocalAppData%\waggle\waggle.db (Windows). Parent directories are created on first run. |
--addr |
WAGGLE_ADDR |
127.0.0.1:4318 |
Bind address for UI, API, and OTLP/HTTP ingest. |
--ingest-addr |
WAGGLE_INGEST_ADDR |
— | Override to split OTLP/HTTP ingest onto its own listener. |
--ui-addr |
WAGGLE_UI_ADDR |
— | Override to split the UI + API onto its own listener. |
--grpc-addr |
WAGGLE_GRPC_ADDR |
127.0.0.1:4317 |
Bind address for OTLP/gRPC ingest. Empty string disables. |
--no-open-browser |
WAGGLE_NO_OPEN |
false |
Skip the browser auto-open on startup. |
--retention |
WAGGLE_RETENTION |
24h |
Drop data older than this (Go duration; 0 disables). |
--log-level |
WAGGLE_LOG_LEVEL |
info |
debug, info, warn, error. |
--dev |
— | false |
Dev mode: do not serve embedded UI, do not open browser. |
--mcp |
WAGGLE_MCP |
true |
Serve the read-only MCP endpoint at /mcp on the UI listener. See Query from an AI agent. |
--tee |
WAGGLE_TEE |
— | Mirror incoming log records to this path (- = stdout). See the Tee section above. |
--tee-service |
WAGGLE_TEE_SERVICE |
— | Comma-separated service.name allow-list. Omit for all services. |
--tee-severity |
WAGGLE_TEE_SEVERITY |
— | Severity floor: trace, debug, info, warn, error, fatal. |
--tee-format |
WAGGLE_TEE_FORMAT |
console |
console (zerolog-style), logfmt, or json (NDJSON). |
--tee-color |
WAGGLE_TEE_COLOR |
auto |
ANSI colour for console format: auto (TTY-detect), always, never. |
When --ingest-addr and --ui-addr differ, waggle binds two HTTP listeners;
otherwise a single listener serves everything on --addr. The OTLP/gRPC
listener is independent of the HTTP split — it always binds --grpc-addr
on its own port (4317 by convention).
# Go (hot-reload via air) + Vite dev server, concurrently.
# Go listens on :4318 (HTTP) + :4317 (gRPC), Vite on :5173 with /v1 and /api proxied to Go.
go tool task devOne-time prerequisites:
go install github.com/air-verse/air@latest
(cd ui && npm install)Tasks are defined in Taskfile.yml and run via
go-task, which is pinned as a module-local tool in
go.mod — no system install needed. Run go tool task with no arguments to
list every target.
Useful targets:
| Task | What it does |
|---|---|
go tool task build |
Build the UI and compile a single static binary into bin/waggle. |
go tool task test |
Run Go and UI tests. |
go tool task typecheck |
tsc --noEmit + go vet. |
go tool task fmt |
gofmt + goimports on Go sources. |
go tool task loadgen -- --rate 20 |
Stream realistic OTel traces / logs / metrics at a running waggle. |
go tool task release:snapshot |
Local goreleaser snapshot (archives + Docker image, no publish). |
cmd/loadgen is a small OTel client that drives realistic trace / log /
metric traffic at a running waggle. It uses the real OTel Go SDK
(otlptracehttp/grpc, otlploghttp/grpc, otlpmetrichttp/grpc)
and switches transport with --protocol http|grpc, so the resulting
payloads exercise the full ingest path on either listener.
# Default: 5 traces/s, no logs, metrics every second
go tool task loadgen
# Metrics only, one export every 10s — good for building up a tidy chart
go tool task loadgen -- --rate 0 --logs-rate 0 --metrics-rate 0 --metrics-interval 10sMetrics emitted per service cover the common host-metrics shapes so
queries like MAX(memory.rss_bytes) and AVG(cpu.utilization) have
something to chart: requests.total (counter), memory.used_bytes,
memory.free_bytes, memory.rss_bytes (gauges), cpu.utilization
(gauge, two cpus), network.bytes_sent, network.bytes_received
(observable counters). Each gauge wobbles deterministically around a
per-service baseline.
Useful flags (go tool task loadgen -- --help for the full list):
--rate/--logs-rate/--metrics-rate— independent rate knobs (set any to0to disable that signal).--metrics-interval— the OTel PeriodicReader cadence.--services— comma-separated subset of trace templates.
cmd/
waggle/ # server entry point
loadgen/ # OTel trace load generator (real OTel SDK)
internal/
api/ # JSON API for the UI (/api/*)
config/ # flag + env parsing
ingest/ # OTLP/HTTP decode + OTLP/gRPC handler + buffered writer
mcpserver/ # read-only MCP server (tools over the query engine)
otlp/ # OTLP -> internal model transform
query/ # structured query builder (validates + compiles to SQL)
server/ # HTTP + gRPC wiring (ingest + UI/API listeners)
store/ # storage seam + SQLite implementation (schema, queries)
ui/ # embedded React build (//go:embed all:dist)
ui/ # Vite + React + TanStack Router + Tailwind source





