Browse resources

Start here

Architecture and trade-offs

How a tracing backend fits in two containers, one app and ClickHouse, what that buys you, and where it stops being the right fit.

knotel is a tracing backend, dashboard and MCP server in one container, with ClickHouse beside it for spans, logs and web analytics and a SQLite file for everything else. This page explains how that works, why it's built this way, and where the design stops being the right fit.

The short version

  • Two containers. Ingest, queries, the web app, auth, MCP and the alert checks are one Bun process; ClickHouse is the other. docker compose up starts both.
  • Your machine, your data. Spans are written to the ClickHouse you run and nowhere else.
  • One columnar store. ClickHouse keeps every span, compressed by column, for as long as its project keeps it. There is no cache of recent spans in front and no slower archive behind: a 30-day chart reads the same table as a 15-minute one.
  • Aggregates are computed from raw spans. ClickHouse scans and aggregates columns fast enough that there are no rollups and no sampling needed for capacity.
  • Priced as a machine. No per-span or per-seat bill: a small VM with 2-4 vCPUs and 4-8 GB of memory handles millions of spans a day.
  • OpenTelemetry-native. Spans and logs arrive as OTLP over HTTP from the bundled browser script, the Workers SDK, or any OTel SDK.

The pieces

Send
Browser t.js
Workers SDK
OTel SDKs, Collector
POST /v1/…
knotel container · ingest
authparsefilter
Keys are checked in memory, then in the SQLite file.
one insert per batch
Storage
ClickHouse
Spans, logs and web events, compressed by column. Everything for as long as each project keeps it.
SQLite in /data
Accounts, projects, keys, alerts, usage and the cache.
Both on your own machine. A TTL deletes rows past their project's retention; there is no cleanup job.
rows
knotel container · query
any rangeClickHouse
who, what, which keySQLite
Answers are cached, so viewers of one range share a query.
answers
Read
Dashboard
/mcp (Claude, IDEs)
Both ask the same query module, so they never disagree.
One container owns both halves: spans travel right into ClickHouse, answers travel back left to whoever asked.

Life of a span

  1. Captured. The browser script patches fetch, listens for errors and reads the navigation timing API; the Workers SDK, for apps on Cloudflare Workers, wraps the handler and uses AsyncLocalStorage to know which request an outgoing fetch belongs to. Both add a W3C traceparent header so the next service continues the same trace.
  2. Batched. The browser sends up to 50 spans per request (sooner when the tab hides, with keepalive). A Worker sends a request's spans after the response, with ctx.waitUntil, so users never wait on telemetry.
  3. Authenticated. The ingest key is hashed and looked up in memory, then in a cache table, then in the project tables, all in the SQLite file. Most requests never leave memory to authenticate.
  4. Parsed. OTLP JSON and protobuf are normalised the same way: hex or base64 ids, numeric or named enums, nanosecond strings. Both read the same OTLP fields, so neither encoding carries less. Well-known attributes (HTTP method, route, status, URL, database system, operation, collection, query) become columns, as do the instrumentation scope, trace state and flags, and the counts of what the sender's SDK dropped; everything else stays as JSON.
  5. Filtered. Project filters drop scanner probes and other noise in memory, before any write.
  6. Stored once. The batch is one insert into ClickHouse's spans table, each row carrying its project's retention in days. Inserts are asynchronous on the server side, which gathers many small batches into one part, but ingest waits for the flush: a 200 means stored.
  7. Metered. Requests, spans, bytes and what was dropped are added to daily usage counters in SQLite after the response.

Reading: one store

Every chart, table and MCP answer comes from one query module, which trims the requested range to what the project still keeps and asks ClickHouse. The spans table is sorted by project, service and time, so one project's window is a narrow range of each column, read in parallel.

Entry spans

Request counts, error rates and latency use only server spans and trace roots, so a request with twenty database calls counts once, with its end-to-end latency.

Percentiles

p50, p95 and p99 come from ClickHouse's t-digest quantiles: approximate, but close enough to read trends from, and computed over every span in the range rather than a sample.

The service map is a self-join

Joining each span to its parent finds every place where the parent belongs to a different service or project: that's an edge. Database and external-host edges come from client spans that no instrumented service answered. No extra instrumentation or topology data is needed.

Home signals are computed on read

The Needs attention list compares each service's current window with the one before (error rate, p95, traffic) when you open Home. There's no background job behind it. Alerts are the scheduled counterpart: a threshold on one of the same numbers, checked every minute by a timer in the knotel process.

Caching

  • Credentials: memory (60 s) → a cache table in SQLite (5 min) → the key tables. Revoking deletes the cache entry at once.
  • Dashboards: every query is cached in memory, then in the cache table, for 30 s (15m) up to 30 min (30d), and everyone looking at the same range shares one result. Past that, the old result is still served while a new one loads in the background, so a viewer never waits on a query just because the cache turned over.
  • Filters and retention: held in memory and refreshed from SQLite, so ingest reads neither per request.

Auth and MCP

Better Auth handles email and password and passkeys, with sessions in SQLite. There's no public sign-up: the first account claims the instance with a setup code, and admins invite everyone else. The public address comes from PUBLIC_URL or the proxy's X-Forwarded-Proto and X-Forwarded-Host.

The MCP server is stateless Streamable HTTP: each request builds the tool set, answers, and exits. Tools call the same query module as the dashboard, so the AI and the UI never disagree.

Why ClickHouse

  • Compression. Spans repeat themselves: the same resource JSON, service names and routes on every row. Stored by column and sorted by service, that compresses around 10× or better, so months of history fit on an ordinary disk.
  • Aggregation over raw data. Counts, error rates and percentiles over millions of spans come straight from the table, so there's nothing to precompute and no view that only holds summaries.
  • Retention as a TTL. Each row expires on its own, and ClickHouse drops expired data as it merges. There is no cleanup job to schedule or fall behind.
  • SQLite for the rest. Accounts, projects, keys and alerts are small and relational, and need no server of their own: one file in /data.

Trade-offs and limits

LimitationWhyWhat it means
One ClickHouse serverThe stack runs a single node, with no replication or shardingIt scales up, with more cores, memory and disk, not out. A failed disk loses what isn't backed up.
Backups are yours to runNothing is managed for youBack up ClickHouse (BACKUP, or a volume snapshot) and the SQLite file in /data. See Configuration.
Disk is the ceiling on historyEverything a project keeps is on the ClickHouse server's diskWatch the Usage page's storage table; shorten retention on the biggest projects or add disk.
Percentiles are approximatet-digest quantilesClose enough for trends and alerts; not exact to the microsecond.
Deletes finish in the backgroundA retention change or a deleted project is a ClickHouse mutationReads stop showing the data at once; disk space comes back as parts merge.
A duplicate span can be counted twice for a whileRetried exports collapse to one row when parts mergeRetries are rare enough that this is noise.
No sampling of its ownEvery span it receives is storedSample in the SDK or a Collector; declare the rate and counts are scaled back up.
OTLP/gRPC is plaintextPort 4317 speaks h2c, as port 3000 speaks HTTPPut TLS in front with the proxy that already serves the site.
Traces and logs, no metricsScopeThere's no /v1/metrics; request rates, errors, latency, CPU and memory come from spans.
No TLS of its ownIt serves plain HTTP on port 3000Run it behind the reverse proxy or tunnel you already use.

Compared with the alternatives

StrengthsCosts
knotelTwo containers on your own machine, one columnar store with per-project retention, traces browsers and Cloudflare Workers out of the box, built-in service map, web analytics and MCP.Young; a single ClickHouse node; no metrics endpoint or roles yet.
HoneycombMature hosted product with a columnar store built for high-cardinality exploration, BubbleUp, SLOs and triggers.Your data lives with a vendor, and pricing grows with event volume.
Jaeger, Grafana Tempo, SigNozOpen source, proven at very large scale with clustered storage, rich ecosystems.More moving parts to run and scale (collectors, storage clusters, object storage).
When knotel fits
Small to mid-sized teams who want end-to-end traces from browser to database on a machine they already run, without paying per event. If you need replicated storage across nodes or metrics alongside traces, a clustered observability stack is the better tool.

Where it goes next

  • Alerts offered from the page you're on, a visual query builder with heatmaps, and OAuth for MCP.

Data model

ClickHouse's spans table holds one row per span: ids, service, name, kind, timings, status, the promoted HTTP and database attributes, the instrumentation scope, trace state and flags, the sender's dropped counts, when ingest received the span, its retention, and attributes, resource, events and links as JSON text. It is partitioned by day and sorted by project, service and time.

ClickHouse tableHolds
spansEvery span, until its project's retention
trace_indexWhere each trace is in time, so opening one reads a day, not every day
logsLog records
web_eventsPage views and events from the browser script
_migrationsWhich ClickHouse migrations have run
SQLite tableHolds
projectProjects, their filters and retention
api_keyIngest keys (SHA-256 hashes)
access_tokenPersonal access tokens (hashes)
alert, alert_destination, alert_state, alert_eventAlerts, where they send, and their history
usage_dailyUsage counters per day, project and metric
user, session, account, verification, passkeyBetter Auth
instanceClaim state and generated secrets
kvThe cache, and the IP → location database

Code layout

PathContents
src/server.tsServer entry: ingest, MCP, the web app, migrations on first request
src/platform/The Bun server, ClickHouse client and migrations, SQLite, the schedule
src/ingest/OTLP parsing, filters, IP geolocation, and the ClickHouse writes
src/server/queries.tsThe read path
src/server/Home signals, alerts, caching, usage metering, server functions
src/mcp/MCP server and tools
src/routes/Pages (TanStack Start)
src/docs/These resource pages
sdk/Browser script and Workers SDK
clickhouse/ClickHouse migrations
migrations/SQLite migrations
docker-compose.yml, dokploy/The stack, and the same as a Dokploy template