Skip to content

danielloader/waggle

Repository files navigation

waggle

waggle bee logo

OTEL viewer for local development.

Go Reference Go Report Card CI Latest Release License

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, and POST /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 for signal_type, span_kind, etc.) and metric_events (Honeycomb-style: the metric's name is an attribute field, so MAX(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-SELECT queries 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 a waggle mcp stdio 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.

Screenshots

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.

Spans Explore Data, filtered to root spans

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.

Metrics dashboard with three stacked charts

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.

Trace waterfall with span detail

The /logs Explore-Data tab — FTS5-indexed bodies with severity badges, service names, and trace-id correlation back to the waterfall.

Logs Explore Data with severity + trace correlation

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.

Tail tab — terminal-style follow view

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.

Install

Homebrew — macOS or Linuxbrew, via the tap:

brew install danielloader/tap/waggle
brew services start waggle   # optional: run as a LaunchAgent

Binary — grab a release archive from Releases, extract, and run:

./waggle

Linux 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 started

Docker — images are published to GitHub Container Registry:

docker run --rm -p 4318:4318 -p 4317:4317 -v $(pwd)/data:/data \
  ghcr.io/danielloader/waggle:latest

From source — requires Go 1.26+ and Node 22+:

go tool task build
./bin/waggle

go install — headless server only (no embedded UI):

go install github.com/danielloader/waggle/cmd/waggle@latest
waggle

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

Usage

One explore page drives everything. The sidebar has two entries — the explore page and the query history:

  • /events is 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, so MAX(requests.total) or P99(memory.used_bytes) resolves through the same field picker as any other attribute.
  • /history — recent queries, deduplicated. Every successful /events query lands in a local query_history table 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 logs to a terminal or file

--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 warn

Colour 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 -R

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

Query from an AI agent (MCP)

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/mcp

Over 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.db

Tools: 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.

Run as a background service

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 waggle runs waggle as a LaunchAgent. Logs go to $(brew --prefix)/var/log/waggle.log. Stop with brew services stop waggle; restart after upgrade with brew services restart waggle.
  • Linux (.deb / .rpm / .apk / Arch): the package ships /lib/systemd/system/waggle.service and creates a waggle system user. The postinstall enables and starts the service on first install, and restarts it after an upgrade. Manage it with the usual systemctl 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.

Query model

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 chart RATE_SUM(requests.total) per method.
  • Ask for MAX(memory.rss_bytes) / 1024 / 1024 style arithmetic via the query builder's aggregation pipeline.
  • Correlate across signals — same trace_id filter works on spans and logs simultaneously.

Config

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

Development

# 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 dev

One-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).

Loadgen

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 10s

Metrics 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 to 0 to disable that signal).
  • --metrics-interval — the OTel PeriodicReader cadence.
  • --services — comma-separated subset of trace templates.

Project layout

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

About

Local OpenTelemetry viewer with Honeycomb-style query builder — OTLP/HTTP ingest into SQLite, trace waterfall, FTS5 log search. Single static binary.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages