Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 at localhost:4317 and open http://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

PortPurposeFallbacks
6666HTTP: web UI + REST API6667–6670, then OS-assigned
4317OTLP/gRPC receiver4318–4321, then OS-assigned

The actually bound addresses are reported by GET /api/health.

Where to go next

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-cask repository 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.exe from 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-viewer binary (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 /api to the server container

Both are wired together by docker-compose.yml:

docker compose up -d --build
  • UI: http://localhost:8080 (nginx + SPA, /api proxied internally)
  • REST API (direct, optional): http://localhost:6666
  • OTLP/gRPC receiver: localhost:4317 — point your exporters there
  • Data persists in the otel-data volume (/data/otel-viewer.duckdb inside 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/)

ModuleResponsibility
lib.rsrun() / run_with_db(): owns the listeners, spawns servers
grpc.rsOTLP/gRPC receiver (tonic): traces, metrics, logs services
db.rsDuckDB layer: one async writer task + a pool of reader connections
rows.rsFlat row types, schema DDL, insert helpers
api.rsaxum REST router + embedded web UI (rust-embed of web/dist)
queries.rsRead queries and response DTOs for the REST API
sqlguard.rsRead-only guard for user SQL from the SQL panel
convert.rsOTLP proto → flat rows
demo.rsDemo telemetry seeding

Data flow

  1. grpc.rs receives OTLP export requests, convert.rs flattens them into SpanRow / LogRow / MetricPointRow.
  2. db.rs funnels all writes through a single writer task (an mpsc channel + one connection) so inserts and the Reset job serialize; each batch is one transaction.
  3. 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

EndpointDescription
GET /api/healthstatus, version, db file, OTLP address
GET /api/statsrow counts per signal, distinct services, time range
GET /api/dbstatsDuckDB 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

EndpointDescription
GET /api/tracestrace 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/logslog list; filters service, q, severity, start_ns, end_ns, trace_id, span_id, limit, offset
GET /api/metricsmetric summaries; filters q, limit
GET /api/metrics/{name}points of one metric; filters service, start_ns, end_ns, limit
GET /api/servicesper-service counts and first/last seen
GET /api/schematable + column listing
GET /api/dashboardslist dashboards (builtin + user ~/.otel-viewer/dashboards/)
GET /api/dashboards/{id}full dashboard document (inputs, panels, SQL templates)

SQL & maintenance

EndpointDescription
POST /api/queryrun read-only SQL ({"sql": "...", "limit": 500})
POST /api/resetdelete 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/EXPLAIN only
  • 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 BY your 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_users is a gauge stored in value_int — use coalesce(value_double, value_int).
  • locust.client.duration histograms 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 RequestDuration histogram and Kestrel connection gauges.
  • Python service (auto-instrumentation) — RED metrics from spans plus the standard http.server.duration histogram and active-requests gauge.

Where dashboards come from

SourceLocationNotes
builtinembedded in the binary (src/dashboards/*.yml)always available
user~/.otel-viewer/dashboards/*.yml / *.yamlrescanned 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 bare NULL (quotes consumed), which is why the ('$x' IS NULL OR … = '$x') idiom turns the filter off. service inputs filter on service_name; attribute inputs read the given JSON key from the table’s span/log/series attributes, or from resource_attributes when the input sets resource: 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

typeexpectsrenders
linetime-like column + numeric columnsmulti-line time chart (same detection as the SQL console)
pointsnumeric x + numeric yscatter plot
barlabel column + numeric columnsvertical bars
histogramone numeric columnclient-side bucketed histogram
diallatest value of first numeric columnradial gauge with auto-scaled max (or set max:)
statlatest value of first numeric columnbig number
tableany result setresults table — numbers right-aligned, a Total row is emphasized
heatmaptime (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 with isfinite(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

PanelWhat it shows
Tracestrace list with duration/error sparkline filters; click for the waterfall and span detail (attributes, events, links, resources)
Logslog stream with severity colors, full-text search and trace correlation
Metricsmetric list (type, unit, last value); click one for per-series line charts, histogram buckets, summary quantiles
SQLread-only SQL console with auto charting — see SQL Console & Charts
Schematables, 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
  • 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.app and the .dmg (use --target universal-apple-darwin for 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 in value_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_count deltas

See SQL Console & Charts for the charting rules, and locust-test/README.md for the full harness documentation.