otel-viewer
otel-viewer is a small, self-hosted OTLP/gRPC collector that stores traces, metrics and logs in an embedded DuckDB database and serves a web UI plus a REST query API — all in a single binary. No external services, no configuration files.
OTLP exporters ──gRPC:4317──▶ otel-viewer ──▶ DuckDB (otel-viewer.duckdb)
│
HTTP :6666 ──┤── REST API (/api/*)
└── web UI (embedded)
Forms
- Server (CLI) —
cargo run, point any OTLP exporter atlocalhost:4317and openhttp://localhost:6666. One binary, in-memory or file-backed DuckDB. - Desktop app — the same stack wrapped in a native window (Tauri 2), with a persistent database in the OS app-data directory. See Desktop App.
Features
- Traces — waterfall view, service/error filters, span detail with attributes, events and links.
- Logs — severity filter, full-text search, trace/span correlation.
- Metrics — gauges, sums, histograms (bucket view), exponential histograms, summaries (quantiles); auto line charts per series.
- SQL console — run read-only SQL directly against the DuckDB tables, with automatic time-series charting of the result. See SQL Console & Charts.
- Storage info — file size, block usage and per-table sizes via the (i) button in the header.
- Reset — one click (well, two — it asks for confirmation) wipes all telemetry data.
Quickstart
cargo run -- --seed-demo # UI/API on :6666, OTLP on :4317, demo data
Then open http://localhost:6666. Point your OTLP/gRPC exporter at
localhost:4317 (standard OTLP port) — the
locust load-test harness is a ready-made source of
realistic telemetry.
Ports
| Port | Purpose | Fallbacks |
|---|---|---|
| 6666 | HTTP: web UI + REST API | 6667–6670, then OS-assigned |
| 4317 | OTLP/gRPC receiver | 4318–4321, then OS-assigned |
The actually bound addresses are reported by GET /api/health.
Where to go next
- Architecture — how the Rust crate is put together
- REST API — endpoint reference
- SQL Console & Charts — query examples and charting rules
- Desktop App — building the Tauri shell
Installation
The desktop app ships as prebuilt binaries attached to every tagged release
on the releases page:
a .dmg for macOS (Apple Silicon) and a setup .exe installer (NSIS) for
Windows x64. Package-manager installs are available via Homebrew and winget.
macOS — Homebrew (custom tap)
The desktop app is distributed through the project’s own Homebrew tap:
brew trust stivio00/otel-viewer # once — brew 7+ distrusts new taps by default
brew tap stivio00/otel-viewer
brew install --cask otel-viewer
Upgrades follow the usual brew upgrade --cask otel-viewer whenever a new
version is released. The tap lives at
stivio00/homebrew-otel-viewer.
A submission to the central
homebrew-caskrepository additionally requires the project to meet Homebrew’s notability bar (75+ stars or 30+ forks/watchers) and ideally a signed + notarized binary — until then the custom tap is the official channel.
Windows — winget
Once the package is published to the winget community repository (microsoft/winget-pkgs):
winget install Stivio00.otel-viewer
Upgrades follow the usual winget upgrade flow.
Not published yet? Until then, download
otel-viewer_<version>_x64-setup.exefrom the releases page and run it.
First launch notes
The binaries are unsigned, so both operating systems will grumble once on first launch:
- macOS Gatekeeper: right-click the app and choose Open, then confirm in the dialog (needed only the first time).
- Windows SmartScreen: click More info → Run anyway in the blue dialog.
The app is self-contained: it runs a local OTLP/gRPC receiver on port 4317 and an HTTP API + UI on 127.0.0.1:6666, storing telemetry in a DuckDB file under your user application-data directory. To start from scratch, delete that file or use the reset button in the app header.
Docker
For server deployments there are two images:
- server — the
otel-viewerbinary (OTLP/gRPC receiver + REST API + embedded web UI) storing telemetry in a DuckDB file on a volume - web — the built React SPA served by nginx, proxying
/apito the server container
Both are wired together by docker-compose.yml:
docker compose up -d --build
- UI:
http://localhost:8080(nginx + SPA,/apiproxied internally) - REST API (direct, optional):
http://localhost:6666 - OTLP/gRPC receiver:
localhost:4317— point your exporters there - Data persists in the
otel-datavolume (/data/otel-viewer.duckdbinside the server container)
To reset all telemetry, either POST /api/reset from the UI/API or remove
the volume (docker compose down -v).
Images
# server
image: ghcr.io/stivio00/otel-viewer:latest
# web (nginx + SPA)
image: ghcr.io/stivio00/otel-viewer-web:latest
The release workflow publishes both to GHCR for every v* tag
(:latest and :0.1.1-style version tags), built for linux/amd64.
On Apple Silicon machines use the native desktop app (.dmg) or add
platform: linux/amd64 under the compose services (emulated).
Building manually
docker build -f Dockerfile.server -t otel-viewer .
docker build -f Dockerfile.web -t otel-viewer-web .
The server image is a two-stage build: Rust toolchain + cmake/clang compile
the crate (DuckDB’s bundled C++ is the slow part), then the binary is copied
into debian:bookworm-slim running as a non-root user. The web image builds
the SPA with pnpm on Node 24 and serves dist/ with nginx.
Architecture
otel-viewer is a cargo workspace-style repo with one library crate, one desktop shell crate and one web frontend:
src/ the `otel-viewer` crate: collector library + CLI server
src-tauri/ the `otel-viewer-tauri` crate: Tauri 2 desktop shell
web/ React + Vite + Tailwind SPA, built into web/dist
locust-test/ aiolocust load-test harness (uv venv)
The Rust crate (src/)
| Module | Responsibility |
|---|---|
lib.rs | run() / run_with_db(): owns the listeners, spawns servers |
grpc.rs | OTLP/gRPC receiver (tonic): traces, metrics, logs services |
db.rs | DuckDB layer: one async writer task + a pool of reader connections |
rows.rs | Flat row types, schema DDL, insert helpers |
api.rs | axum REST router + embedded web UI (rust-embed of web/dist) |
queries.rs | Read queries and response DTOs for the REST API |
sqlguard.rs | Read-only guard for user SQL from the SQL panel |
convert.rs | OTLP proto → flat rows |
demo.rs | Demo telemetry seeding |
Data flow
grpc.rsreceives OTLP export requests,convert.rsflattens them intoSpanRow/LogRow/MetricPointRow.db.rsfunnels all writes through a single writer task (an mpsc channel + one connection) so inserts and theResetjob serialize; each batch is one transaction.- REST handlers use pooled reader connections via
Db::read(), with all blocking DuckDB work dispatched to tokio’s blocking pool.
Storage
Three tables — spans, log_records, metric_points — defined in
rows.rs (SCHEMA_SQL). Attributes/events/links/histogram buckets are
stored as JSON strings and parsed on the way out. Timestamps are BIGINT
nanoseconds (start_ns, time_ns, ts_ns).
The web UI embed
api.rs uses rust-embed with #[folder = "web/dist"]:
- debug builds read the files live from disk (edit + refresh),
- release builds bake them into the binary.
So web/dist must exist before a release build; the Tauri config runs
pnpm -C web build as beforeBuildCommand to guarantee that.
The web UI (web/)
React 19 + Vite + Tailwind 4. The API client lives in web/src/lib/api.ts;
panels (Traces, Logs, Metrics, SQL, Schema) in web/src/components/panels/.
Server state via @tanstack/react-query, UI state via zustand, charts via
recharts. See Web UI.
The desktop shell (src-tauri/)
Wraps the library crate: opens the persistent DuckDB, starts the collector
in-process and shows the UI through a custom otelview:// URI scheme that
calls the axum router in-process — the webview never makes a loopback
HTTP connection, which sidesteps macOS App Transport Security and the
macOS 26 Local Network privacy controls that silently block such
navigations. Details in Desktop App.
REST API
All endpoints are served on the HTTP port (6666 by default) under /api.
CORS is permissive, so the API can be called from any origin. Timestamps are
returned as ISO strings; HUGEINT values come back as strings (a DuckDB JSON
limitation).
Health & stats
| Endpoint | Description |
|---|---|
GET /api/health | status, version, db file, OTLP address |
GET /api/stats | row counts per signal, distinct services, time range |
GET /api/dbstats | DuckDB storage stats (see below) |
GET /api/dbstats returns:
{
"db_file": "~/.local/share/otel-viewer.duckdb",
"file_size_bytes": 12288,
"database_size": "0 bytes",
"block_size": 262144,
"total_blocks": 0,
"used_blocks": 0,
"free_blocks": 0,
"checkpoint_count": null,
"memory_bytes": 2144256,
"tables": [
{ "table_name": "spans", "estimated_size": 168,
"column_count": 23, "index_count": 0 }
]
}
Telemetry queries
| Endpoint | Description |
|---|---|
GET /api/traces | trace list; filters service, q, start_ns, end_ns, min_duration_ms, errors_only, limit, offset |
GET /api/traces/{trace_id} | full span list of one trace |
GET /api/logs | log list; filters service, q, severity, start_ns, end_ns, trace_id, span_id, limit, offset |
GET /api/metrics | metric summaries; filters q, limit |
GET /api/metrics/{name} | points of one metric; filters service, start_ns, end_ns, limit |
GET /api/services | per-service counts and first/last seen |
GET /api/schema | table + column listing |
GET /api/dashboards | list dashboards (builtin + user ~/.otel-viewer/dashboards/) |
GET /api/dashboards/{id} | full dashboard document (inputs, panels, SQL templates) |
SQL & maintenance
| Endpoint | Description |
|---|---|
POST /api/query | run read-only SQL ({"sql": "...", "limit": 500}) |
POST /api/reset | delete all telemetry data (spans, logs, metric points) |
POST /api/query enforces the rules of sqlguard: a single read-only
statement, blocklisted keywords, -- comments stripped before scanning, and
an automatic LIMIT when none is given. Histogram bounds/bucket counts are
JSON strings — cast in SQL with ::DOUBLE[].
Examples
curl -s localhost:6666/api/stats | jq
curl -s 'localhost:6666/api/traces?errors_only=true&limit=5' | jq
curl -s -X POST localhost:6666/api/query \
-H 'content-type: application/json' \
-d '{"sql": "select service_name, count(*) n from spans group by 1 order by n desc"}' | jq
SQL Console & Charts
The SQL panel runs read-only queries against the live DuckDB database — the same tables the collector writes. The panel ships with example queries (load-testing focused); pick one from the dropdown to get started.
Guard rules
- exactly one statement,
SELECT/WITH/PRAGMA/DESCRIBE/SHOW/EXPLAINonly - mutating keywords are blocklisted;
--comments are stripped before scanning - a LIMIT is appended automatically if you forget one
Auto line chart
When a result contains a time-like column plus numeric columns, a line
chart is rendered above the table (toggle with the chart button):
- X axis — a column named like
ts / time / timestamp / date, or any column whose values parse as epoch nanoseconds/microseconds/milliseconds/ seconds or ISO datetime strings. - Lines — every remaining numeric column becomes a line, up to 8. Select multiple numeric columns to get multiple lines. Columns that look like epoch timestamps are skipped as y-values.
- Rows are sorted by time automatically;
ORDER BYyour time column anyway.
Tips: cast text-typed numbers with ::DOUBLE, and alias columns
(AS p95_ms) to label the lines.
Load-testing examples
The built-in examples assume aiolocust telemetry (see Load Testing). Two quirks matter:
locust.current_usersis a gauge stored invalue_int— usecoalesce(value_double, value_int).locust.client.durationhistograms are cumulative (aggregation_temporality = 2): bounds and bucket_counts are JSON strings (cast with::DOUBLE[]) and rates/percentiles must diff consecutive snapshots.
Example — concurrent users over time (gauge stored in value_int):
SELECT to_timestamp(ts_ns / 1e9) AS ts,
coalesce(value_double, value_int) AS users
FROM metric_points
WHERE metric_name = 'locust.current_users'
ORDER BY ts_ns
The panel’s built-in examples also include a latency p95 vs time query
(latest cumulative snapshot per 30s bucket, p95 read off the bucket counts)
and a requests per second query — Prometheus-style: per-series counter
increase (hist_count deltas, resets clamped to 0) summed inside each 30s
bucket and divided by the bucket width, which stays smooth even when export
intervals jitter. Open the SQL panel’s example dropdown to run them as-is.
Users over time, charted automatically:
Requests per second from the cumulative histogram deltas:
Dashboards
The Dashboards view (preset selector in the header) renders YAML-defined dashboards: a time-range control, filter inputs and a grid of SQL-driven panels — charts, dials, stats and heatmaps computed from the live DuckDB database. Think of it as the SQL console with saved, parameterized queries and proper visualizations.
Three dashboards ship built in:
- Locust load test — the classic locust web page rebuilt on raw
telemetry: an endpoint stats table (count / failures / avg / max / rate),
requests/s, latency percentiles, user count, a latency-bucket heatmap and
per-endpoint / per-error request counts. Works with the
locust-test/aiolocust harness. - .NET service (auto-instrumentation) — RED metrics from spans plus the
ASP.NET Core
RequestDurationhistogram and Kestrel connection gauges. - Python service (auto-instrumentation) — RED metrics from spans plus
the standard
http.server.durationhistogram and active-requests gauge.
Where dashboards come from
| Source | Location | Notes |
|---|---|---|
| builtin | embedded in the binary (src/dashboards/*.yml) | always available |
| user | ~/.otel-viewer/dashboards/*.yml / *.yaml | rescanned on every request — drop a file in, hit refresh, no restart |
GET /api/dashboards lists them; GET /api/dashboards/{id} returns the
full document. The sidebar marks user dashboards with a user badge.
Writing a dashboard
name: my-service # -> dashboard id (unique, shown in the URL/API)
title: My Service
description: |
Shown under the title. Multi-line is fine.
inputs:
- name: service # token becomes $service in panel SQL
label: Service
type: service # dropdown of known service names
default: ""
- name: route
label: HTTP route
type: attribute # dropdown of distinct values of one attribute
table: spans # spans | logs | metrics
key: http.route # JSON attribute key on that table
default: ""
- name: instance
label: Instance
type: attribute
table: metrics
key: service.instance.id
resource: true # read from resource_attributes instead of
default: "" # series/span/log attributes
- name: status
type: select # fixed choices
choices:
- value: "200"
label: OK
- value: "500"
label: Error
panels:
- id: rps
title: Requests / second
type: line # line | points | bar | histogram | dial | stat | heatmap
unit: req/s # shown as a badge in the panel header
span: 2 # optional: full width on wide screens (default 1)
sql: |
SELECT time_bucket(INTERVAL '30 seconds', to_timestamp(start_ns / 1e9)) AS ts,
count(*) / 30.0 AS rps
FROM spans
WHERE ('$service' IS NULL OR service_name = '$service')
AND ('$route' IS NULL OR json_extract_string(span_attributes, '$."http.route"') = '$route')
AND start_ns BETWEEN $from_ns AND $to_ns
GROUP BY ts ORDER BY ts
Tokens
Panel SQL is a template. Before executing, the UI substitutes:
$from_ns/$to_ns— the selected time range as epoch nanoseconds (“All time” =0…9223372036854775807).$<input-name>— the selected value, escaped as a SQL string literal. An empty selection substitutes a bareNULL(quotes consumed), which is why the('$x' IS NULL OR … = '$x')idiom turns the filter off.serviceinputs filter onservice_name;attributeinputs read the given JSON key from the table’s span/log/series attributes, or fromresource_attributeswhen the input setsresource: true(e.g.service.instance.id).
Everything runs through the same read-only guard as the SQL console
(src/sqlguard.rs): single statement, blocklisted keywords, LIMIT appended
when missing. You cannot mutate data from a dashboard.
Panel types
| type | expects | renders |
|---|---|---|
line | time-like column + numeric columns | multi-line time chart (same detection as the SQL console) |
points | numeric x + numeric y | scatter plot |
bar | label column + numeric columns | vertical bars |
histogram | one numeric column | client-side bucketed histogram |
dial | latest value of first numeric column | radial gauge with auto-scaled max (or set max:) |
stat | latest value of first numeric column | big number |
table | any result set | results table — numbers right-aligned, a Total row is emphasized |
heatmap | time (x) + numeric (y) + numeric (weight) | time × bucket grid, like Grafana’s |
Time selector
The toolbar offers quick relative ranges (All / 5m … 7d), ‹/› to step
the window by its own width, a Now button (re-anchor to the latest data,
keep the width), a live window-width chip, labeled From/To date-time
pickers, and +10m / +15m / +1h nudge buttons. Any manual change switches
to a custom absolute window.
Notes for metric panels:
- Histogram bounds/counts are JSON strings — cast with
::DOUBLE[]. locust.client.duration(and most OTLP histograms) are cumulative: diff consecutive snapshots (cnt - lag(cnt) OVER (PARTITION BY series ORDER BY ts_ns)) before bucketing — see the built-in locust dashboard for the full pattern.- Histogram bounds may include
+Inf; filter withisfinite(b)before using them as axes, and coalesce open-bucket percentiles to the last finite bound. - HUGEINT results (
sum()over big tables) arrive as strings — the charts parse numeric strings, so they still plot.
Screenshots
See the built-in Locust load test dashboard with the locust-test
harness running (page: Load Testing).
Web UI
The UI is a React 19 SPA (Vite + Tailwind 4 + recharts) served by the collector itself — same origin as the API, so no CORS setup is ever needed.
Panels
| Panel | What it shows |
|---|---|
| Traces | trace list with duration/error sparkline filters; click for the waterfall and span detail (attributes, events, links, resources) |
| Logs | log stream with severity colors, full-text search and trace correlation |
| Metrics | metric list (type, unit, last value); click one for per-series line charts, histogram buckets, summary quantiles |
| SQL | read-only SQL console with auto charting — see SQL Console & Charts |
| Schema | tables, columns and row counts |
The metric detail view draws a line chart per series and, for histograms, the bucket distribution of the latest point:
Layout presets (Default / Traces / Logs / Metrics / SQL) and a time-range selector live in the header.
Header controls
- traces/spans/logs/metrics/services — live counters (5s poll)
- otlp — the OTLP/gRPC endpoint; click to copy
- time range + layout preset selectors
- auto-refresh selector — refetch everything on an interval (off / 100ms / 500ms / 1s / 2s / 5s / 10s)
- refresh — refetch everything now
- trash — reset the database: click once to arm (button turns red, 4s
window), click again to delete all telemetry data.
POST /api/reset. - sun/moon — theme toggle (persisted)
- (i) — DuckDB storage info: file path and size, database size, block usage (used/total/free, block size), checkpoint count, memory usage, and per-table row estimates with column and index counts. Updates live while open.
Development
cargo run -- --seed-demo # collector: UI/API on :6666, OTLP on :4317
pnpm -C web dev # vite on :5173, proxies /api to :6666
The vite dev server proxies /api/* to the collector, so the browser only
ever sees one origin. Hot reload applies to UI code; telemetry keeps flowing
into the collector.
Build the UI into web/dist (embedded into release binaries via rust-embed):
pnpm -C web build
Crash safety
The layout is wrapped in an error boundary: if a panel throws during render, the app shows a crash card with the error and a reload button instead of a blank window.
Desktop App
The desktop shell (src-tauri/, crate otel-viewer-tauri) wraps the entire
stack — OTLP receiver, DuckDB, REST router, web UI — in one native window
via Tauri 2. No external services; the collector runs in-process.
How the window is served: otelview://
The webview does not load http://127.0.0.1:6666. Instead it uses the
custom otelview:// URI scheme (https://otelview.localhost/ on Windows),
registered with register_asynchronous_uri_scheme_protocol in
src-tauri/src/lib.rs. Scheme requests are answered by calling the axum
router in-process (otel_viewer::run_with_db shares the Db handle
with the TCP server).
Why: on macOS 26 (Tahoe), WKWebView silently refuses navigations to plain
http://127.0.0.1:<port> — Local Network privacy hardening. No error, no
TCP connection, permanent about:blank (a white window). App Transport
Security exceptions in Info.plist do not help. The custom scheme makes
the webview’s traffic a pure in-process IPC call, which no ATS/Local-Network
policy can block.
The router still binds a real TCP port for external use — open
http://127.0.0.1:6666 in any browser, or curl the API — and the OTLP/gRPC
receiver binds 127.0.0.1:4317 (with fallbacks) for exporters.
Runtime facts
- Persistent DuckDB at
- macOS:
~/Library/Application Support/com.otelviewer.desktop/otel-viewer.duckdb - Windows:
%APPDATA%\com.otelviewer.desktop\otel-viewer.duckdb
- macOS:
- Demo telemetry is seeded when the database is empty
- Single instance: launching a second copy focuses the first
- All listeners bind 127.0.0.1 only
Build
pnpm install # tauri CLI (root package.json)
pnpm -C web install # web UI dependencies
pnpm tauri build
Outputs:
- macOS:
src-tauri/target/release/bundle/macos/otel-viewer.appand the.dmg(use--target universal-apple-darwinfor a universal binary) - Windows: NSIS installer under
src-tauri/target/release/bundle/nsis/
Prerequisites and troubleshooting are in src-tauri/README.md.
Load Testing
The repo ships a load-test harness that doubles as a realistic telemetry
source: locust-test/ uses aiolocust with
OpenTelemetry exporters pointed at otel-viewer.
Quickstart
cd locust-test
uv sync # creates .venv
uv run uvicorn api:app --port 8000 # the system under test
OTEL_METRIC_EXPORT_INTERVAL=5000 uv run aiolocust \
--host http://localhost:8000 --users 10 --duration 60
(The collector must be running first — cargo run -- --seed-demo or the
desktop app.)
What it produces
- traces and logs per request, tagged with the endpoint name
locust.current_users— gauge (stored invalue_int)locust.client.duration— cumulative histogram (aggregation_temporality = 2); bounds and bucket_counts are JSON strings, cast with::DOUBLE[]- errors and failures as log records with severity ERROR
Analyzing it
Open the SQL panel and run the built-in examples:
- Locust: users over time
- Locust: latency p95 vs time — diffs consecutive cumulative snapshots
- Locust: requests per second — rate from
hist_countdeltas
See SQL Console & Charts for the charting rules, and
locust-test/README.md for the full harness documentation.