# Conduit documentation (full)
> Secure, self-hosted observability for AI agents: traces, logs and metrics over mTLS, stored in ClickHouse and VictoriaMetrics, with a server-rendered UI. Go services, Go and Python SDKs.
---
# Conduit
> Secure, self-hosted observability for AI agents: traces, logs and metrics over mTLS, stored in ClickHouse and VictoriaMetrics, with a server-rendered UI. Go services, Go and Python SDKs.
Conduit collects traces from AI agents. Each trace is a run broken into spans for planning, tool calls and LLM calls, with status, errors, token counts and cost. Conduit stores the traces and shows them in a fast, server-rendered UI. It's designed for teams that want agent telemetry inside their own infrastructure, with a small and auditable attack surface.
## Highlights
- **Identity you can trust.** Agents authenticate with mTLS client certificates, and the certificate's CommonName is the agent's identity. An agent can't spoof it with an attribute.
- **Hardened ingestion.** Every span is validated before it reaches storage. Rate limits apply per agent and per telemetry item, so batching can't get around them.
- **LLM-aware traces.** Model, tokens, cost and errors are recorded per span and rolled up per trace. Prompts and completions are stored separately from span metadata.
- **A UI that's quick to read.** One row per trace, filtering by time, agent and errors, stable paging, and a span tree with a waterfall timeline.
- **Small and auditable.** Each service is a static Go binary in a `FROM scratch` image. The UI uses no JavaScript framework, has a strict Content-Security-Policy, and embeds its own assets.
- **Production basics included.** UI sign-in with a login form or Google Cloud IAP, health and readiness endpoints, Kubernetes manifests, and a CI pipeline that runs the whole stack end to end.
## How it works
```mermaid
flowchart LR
A["Agent
Go or Python SDK"] -- "gRPC + mTLS :4317" --> G["Gateway
auth · validation · rate limits"]
G -- "JetStream (work queue)" --> N[("NATS")]
N --> C["Collector"]
C --> CH[("ClickHouse
spans, LLM data")]
C -. "best effort" .-> VM[("VictoriaMetrics
span durations")]
C -. "OTLP/HTTP, best effort" .-> T[("Tempo
traces")]
CH --> U["UI
login or GCP IAP"]
```
The gateway accepts spans only from agents holding a certificate signed by your CA. The collector writes each span to ClickHouse, the system of record, and redelivers it if that write fails. It also copies spans to VictoriaMetrics and Tempo on a best-effort basis. The UI reads from ClickHouse. See [Architecture](architecture.md) for details.
## Quick start
You need Docker (Compose v2, 2 GiB of memory). One command runs the whole
platform from the signed release images:
```bash
git clone https://github.com/tjscanlan/conduit && cd conduit
make quickstart # or: ./deployments/quickstart/quickstart.sh
```
It generates a dev CA, agent certificates, and a UI login, waits until
everything is healthy, and prints your login. Open :
a simulated `demo-agent` is already sending traces, and a Get started page
walks you through sending your own agent's first trace. The banner also shows how
to send a trace from your own code as `my-agent`; CI checks that a cold start
reaches the first trace in under five minutes.
Releases also ship [`conduitctl`](cli.md), the same
quickstart as a single binary with no checkout needed: `conduitctl
quickstart`, plus `agent-cert` and `doctor`.
`quickstart.sh status` shows the login again, `down` stops the stack (keeping
data), and `reset` deletes everything. Next, run one of the
[examples](examples.md) (hello-trace, tool calling, asyncio, Claude,
OpenAI) or instrument your own agent with the [Go SDK](sdk-go.md) or
[Python SDK](sdk-python.md). To work
on Conduit itself (building images from source), see
[Deployment](deployment.md#local-docker-compose-from-source).
## Documentation
**Read the docs at **, including the [visual roadmap](https://tjscanlan.github.io/conduit/roadmap.html). The same pages are in [`docs/`]().
**For AI agents:**
- [`llms.txt`](https://tjscanlan.github.io/conduit/llms.txt) indexes the docs and links each page's raw Markdown.
- [`llms-full.txt`](https://tjscanlan.github.io/conduit/llms-full.txt) has all the docs in one file.
- [`AGENTS.md`](AGENTS.md) tells coding agents how to build, test and change this repo.
| Guide | What's in it |
|-------|--------------|
| [Architecture](architecture.md) | Components, data flow, delivery guarantees and storage schema |
| [Security](security.md) | The threat model, mTLS identity, rate limiting, UI authentication and browser hardening |
| [Deployment](deployment.md) | Docker Compose, Kubernetes, health checks, upgrades and configuration reference |
| [Go SDK](sdk-go.md) · [Python SDK](sdk-python.md) | Instrumenting agents |
| [Roadmap](roadmap.md) | What's shipped, what's next and the open decisions |
| [Releasing](releasing.md) | Versioning policy, verifying signed images, upgrading and cutting a release |
| [Changelog](CHANGELOG.md) | What changed in each release |
| [Contributing](contributing.md) | Development setup, tests, code generation and pull requests |
| [Brand](brand.md) | Logo, palette and accessibility rules for the UI |
## Project status
Conduit is **pre-1.0**. The core pipeline is tested end to end in CI on every push: SDK → gateway → NATS → collector → ClickHouse, VictoriaMetrics, Tempo → UI. APIs and the storage schema may still change between releases. See the [roadmap](roadmap.md).
## License
[Apache License 2.0](LICENSE)
---
# Architecture
Conduit is three Go services (gateway, collector and UI) and two SDKs (Go
and Python), built on NATS JetStream, ClickHouse, VictoriaMetrics and Grafana
Tempo. This document describes how data moves through the system, what each
component guarantees, and how the data is stored.
## Components
| Component | Source | Role |
|-----------|--------|------|
| **SDKs** | `sdk/go`, `sdk/python` | Create spans in the agent process and send them to the gateway over gRPC with mTLS |
| **Gateway** | `cmd/gateway` | Authenticates agents, validates spans, rate-limits each agent and publishes to NATS |
| **Collector** | `cmd/collector` | Consumes from NATS and writes to ClickHouse (required), VictoriaMetrics and Tempo (both best-effort) |
| **UI** | `cmd/ui` | Server-rendered trace list and trace detail, read from ClickHouse, with sign-in |
| NATS JetStream | | A durable buffer between ingestion and storage. Spans, logs and metrics use separate work-queue streams (`SPANS`, `LOGS`, `METRICS`), so a backlog in one never blocks the others. |
| ClickHouse | | The system of record for spans, LLM data and trace rollups |
| VictoriaMetrics | | Agent metrics (the system of record for them) and span duration metrics |
| Tempo | | A copy of each trace, in OTLP format, for Grafana |
## Data flow
```mermaid
sequenceDiagram
autonumber
participant SDK as Agent SDK
participant GW as Gateway
participant N as NATS (SPANS)
participant C as Collector
participant CH as ClickHouse
participant VM as VictoriaMetrics
participant T as Tempo
SDK->>GW: IngestSpan (gRPC, mTLS client cert)
Note over GW: auth → rate limit → validate
GW->>N: publish span (JSON)
GW-->>SDK: accepted / rejection reason
N->>C: deliver (durable consumer)
C->>CH: insert (batch API, nanosecond timestamps)
alt ClickHouse insert fails
C-->>N: Nak, redeliver in 5s
else stored
C->>VM: span duration (best effort)
C->>T: OTLP/HTTP span (best effort, OTLP-shaped IDs only)
C-->>N: Ack (deleted from the work queue)
end
```
1. **SDK.** Spans are created in the agent process. Trace and span IDs are
sized to the W3C and OTLP standards: 16-byte trace IDs and 8-byte span IDs.
Context propagates through `context.Context` in Go and through
`contextvars` in Python, so nesting is correct across goroutines,
threads and asyncio tasks. Sending is synchronous, one RPC per span.
2. **Gateway.** Handles each request in this order:
- **Authenticate** the TLS 1.3 client certificate against the configured
CA. The agent's identity is the certificate's CommonName.
- **Rate-limit** with a token bucket per identity, charged per telemetry
item.
- **Validate** the span, including ID charset and size, name, status,
timestamps, attribute limits and LLM field bounds.
- **Publish** to the `SPANS` stream.
The wire API is Conduit's own protobuf `IngestionService`
(`proto/ingestion.proto`), not OTLP.
3. **NATS JetStream.** A work queue: each span is deleted once the
collector acknowledges it, and the stream has a 7-day maximum age as a
backstop.
4. **Collector.** At-least-once delivery into ClickHouse. A failed insert is
retried after a 5-second delay, and a message that can't be decoded is
discarded for good. Writes to VictoriaMetrics and Tempo are best-effort:
their failures are logged, not retried, so they can never duplicate rows
in ClickHouse.
5. **UI.** Reads only from ClickHouse:
- **Trace list:** the queries are always bounded by a time window, and
paging uses a keyset cursor.
- **Trace detail:** capped at 2000 spans per trace.
- **Sign-in:** a login form with signed session cookies, or Google Cloud
IAP.
## Delivery and failure behavior
| Failure | Effect |
|---------|--------|
| The gateway rejects a span (invalid, over the rate limit, unauthenticated) | The SDK gets an error. `Span.End` / `span.end()` raises or returns it, with the reason. |
| NATS is unavailable | The gateway fails the publish and returns an error to the SDK. Its `/readyz` reports `nats: unavailable`. |
| ClickHouse is unavailable | The collector naks, NATS keeps the span, and it's redelivered every 5 seconds until ClickHouse returns, for up to 7 days. |
| VictoriaMetrics or Tempo is unavailable | Logged. The span is still stored in ClickHouse. |
| A collector instance restarts | The durable consumer resumes, and unacknowledged spans are redelivered. |
The SDKs don't retry. A failed send is reported to the caller, who decides
what to do.
## Storage schema
### Schema management
The schema is defined by numbered migrations in
`internal/storage/migrations/`, which are embedded in the binaries.
- **Who applies them:** the collector, at startup, before it reconciles
retention. `collector migrate` does the same and exits, for running as a
Job before a rollout. The UI never changes the schema.
- **Bookkeeping:** applied versions are recorded in `schema_migrations` with
a SHA-256 checksum. If a migration is edited after it was applied, or the
database has a version the binary doesn't know, the collector refuses to
start.
- **Idempotency:** ClickHouse has no DDL transactions or locks, so every
migration must be idempotent (`IF NOT EXISTS` / `IF EXISTS`), and a test
enforces it. That makes it safe for two collectors to start at once.
- **Existing installs:** these adopt migrations automatically. Migration
0001 is the original DDL, so it applies as a no-op and is recorded.
### ClickHouse `spans`
| Column | Type | Notes |
|--------|------|-------|
| `trace_id`, `span_id`, `parent_id` | `String` | `parent_id` is empty for root spans |
| `agent_name` | `String` | Always the mTLS CommonName, never a client-supplied value |
| `span_name` | `String` | |
| `start_time`, `end_time` | `DateTime64(9, 'UTC')` | Nanosecond precision, written with the native batch protocol |
| `duration_ms` | `UInt64` | |
| `status` | `Enum8('ok', 'error', 'unset')` | |
| `attributes` | `Map(String, String)` | Includes `error.message` for failed spans |
| `llm_model`, `llm_prompt`, `llm_completion` | `Nullable(String)` | Set only on LLM spans |
| `llm_input_tokens`, `llm_output_tokens` | `Nullable(UInt32)` | |
| `llm_cost_usd` | `Nullable(Float64)` | |
| `ingested_at` | `DateTime` | Defaults to `now()` |
The table is a `MergeTree`, partitioned by day and ordered by
`(agent_name, trace_id, start_time)`. Rows expire after
**`SPAN_RETENTION_DAYS`** (default 90, range 1–3650).
- **Who sets it:** the collector owns retention, as part of owning the schema. On startup it compares the
table's TTL with the setting and runs `ALTER TABLE spans MODIFY TTL …`
only if they differ. Shortening retention lets ClickHouse delete rows
that are now past it.
- **The UI** only creates the tables if they're missing, and never alters
them.
### ClickHouse `logs`
Log records sent in `IngestBatch`. Each is validated at the gateway: the
severity enum, a body of 1 byte to 32 KiB, a timestamp no more than an hour
in the future, well-formed optional `trace_id` and `span_id` (a span needs
a trace), and the shared attribute limits. Agent identity comes from the
certificate, as for spans.
| Column | Type | Notes |
|--------|------|-------|
| `timestamp` | `DateTime64(9, 'UTC')` | Defaults to the receive time if the client sends none |
| `agent_name` | `String` | The mTLS CommonName |
| `severity` | `Enum8('unspecified', 'debug', 'info', 'warn', 'error')` | |
| `body` | `String` | |
| `attributes` | `Map(String, String)` | |
| `trace_id`, `span_id` | `String` | Empty unless the log belongs to a trace or span |
The table is partitioned by day and ordered by `(agent_name, timestamp)`.
`SPAN_RETENTION_DAYS` applies to it as well.
### VictoriaMetrics: agent metrics
Gauges and counters sent in `IngestBatch` are written to VictoriaMetrics.
The gateway validates them:
- **Names** must be valid Prometheus names. `conduit_*` is reserved for
Conduit's own series, so agents can't forge them, and `__*` is reserved
too.
- **Labels:** at most 32, with Prometheus-style keys and values of at most
1 KiB.
- **Values** must be finite, and counters must be ≥ 0.
- **The `agent` label** is always set from the certificate.
For metrics, VictoriaMetrics is the system of record, so a failed write is
retried, unlike the best-effort span-duration export. Label values are
escaped for the Prometheus text format (backslash, quote and newline), so a
value can't inject extra samples.
### ClickHouse `trace_summary`
A materialized view (`AggregatingMergeTree`) with per-trace rollups: start,
end, span count, error count, tokens and cost. It uses `SimpleAggregateFunction`
columns, so rows from separate inserts merge correctly. Query it with the
matching aggregate functions and `GROUP BY trace_id`. Nothing in the UI
reads it yet.
### What the UI reads
| Query | Bounded by |
|-------|-----------|
| Trace list | A time window (1h, 24h or 7d), up to 100 traces per page, keyset paging |
| Trace detail | Up to 2000 spans, ordered by start time |
| Agent suggestions | A time window, up to 200 names |
| Logs page | A time window, up to 200 records, newest first. Filters by agent, minimum severity, case-insensitive text and trace. |
The UI never reads prompts or completions. See [Roadmap: open decisions](roadmap.md#open-decisions).
## Sensitive data
By default, prompts and completions are stored in ClickHouse whenever an SDK
records them with `RecordLLMCall` / `record_llm_call`.
**To never store them, set `STORE_LLM_PAYLOADS=false` on the gateway.**
- **What's dropped:** prompts and completions are removed at the gateway,
before anything is published to NATS, logged or stored.
- **What's kept:** the model, token counts and cost are always kept.
- **Validation:** a span whose prompt would have been too large is still
accepted, because the prompt is dropped before validation.
- **Typos:** an unrecognized value stops the gateway at startup, rather
than silently storing payloads.
With storage on, treat ClickHouse access as access to prompt data.
Payloads already stored aren't removed when you turn the switch off.
## Operational endpoints
Every service exposes `/livez` and `/readyz`. The gateway and collector serve
them on `HEALTH_ADDR` (default `:8081`); the UI serves them on its main port.
See [Deployment: health checks](deployment.md#health-checks).
## Design decisions
- **Separate health port for the gateway.** Its gRPC port requires mTLS
client certificates, which Kubernetes and compose probes can't present.
- **Generated code is committed.** The Go and Python protobuf stubs are in
the repository, so a checkout builds with nothing but Go. CI fails if
they drift from the `.proto` files.
- **No JavaScript framework.** The UI uses `html/template`, htmx and two
small scripts, all embedded in the binary. Its strict CSP rules out
inline scripts and styles.
- **ClickHouse as the system of record.** Only ClickHouse failures trigger
redelivery. Treating the other sinks as best-effort keeps duplicate rows
out of ClickHouse.
---
# Deployment
Conduit runs as three binaries, `gateway`, `collector` and `ui`. Each is
shipped as a `FROM scratch` image built from `deployments/docker/Dockerfile`
with `--build-arg BINARY=`. The binaries depend on NATS JetStream,
ClickHouse, VictoriaMetrics and Tempo.
| Component | Port | Talks to |
|-----------|------|----------|
| gateway | 4317 (gRPC, mTLS); 8081 health | NATS |
| collector | 8081 health | NATS, ClickHouse, VictoriaMetrics, Tempo (OTLP/HTTP :4318) |
| ui | 8080 (HTTP; `/healthz` and `/readyz` unauthenticated) | ClickHouse |
## Health checks
Every service exposes unauthenticated health endpoints over plain HTTP:
| Endpoint | Meaning | Gateway / collector | UI |
|----------|---------|---------------------|----|
| `/livez` (UI: `/healthz`) | The process is up | `HEALTH_ADDR`, default `:8081` | main port, 8080 |
| `/readyz` | Dependencies are reachable: NATS for the gateway; NATS and ClickHouse for the collector; ClickHouse for the UI | `HEALTH_ADDR` | main port |
- **Why a separate port for the gateway:** its gRPC port requires mTLS client
certificates, which probes can't present.
- **What `/readyz` returns:** a 200 or a 503, with each check reported only
as `ok` or `unavailable`. The underlying errors go to the service log, so
the endpoint never exposes hostnames or credentials.
- **Health checks in scratch images:** the images have no shell or curl, so
each binary includes `/app healthcheck`, which calls its own `/readyz` and
exits 0 or 1. Compose health checks use it.
- **Keep `HEALTH_ADDR` internal:** don't expose it outside the cluster.
## Local: quickstart
`make quickstart` (or `deployments/quickstart/quickstart.sh`) runs the
released images with nothing but Docker:
- **Credentials:** the one-shot `conduit-init` container writes a dev CA, the
gateway certificate, client certificates for `demo-agent` and `my-agent`,
and a random UI login into `deployments/quickstart/.conduit/` (gitignored).
It runs as your user, so the agent keys are readable by your own code, and
it keeps existing files on later runs.
- **Secrets:** the script writes a random ClickHouse password and session key
to `deployments/quickstart/.env` (gitignored, owner-only).
- **Network:** only the UI (`127.0.0.1:8080`) and the gateway
(`127.0.0.1:4317`) listen, on loopback. Change the ports with
`CONDUIT_UI_PORT` and `CONDUIT_GATEWAY_PORT`.
- **Small machines:** ClickHouse uses a low-memory profile and system log
tables are off, so the stack fits a 2 GiB Docker VM. Span retention is
7 days.
- **Demo data:** a simulated `demo-agent` (no model calls; every span has
`demo=true`) sends a run every 8 seconds. Start without it using
`CONDUIT_DEMO_AGENT=0`, or stop it with
`docker compose -p conduit-quickstart stop demo-agent`.
- **Version:** images default to `v0.2.0`; set `CONDUIT_VERSION` to pick
another release.
**Get started:** until your own agent sends a trace, opening the UI lands
on a Get started page (`/start`). It checks that the stack and the sample
data are working, and shows the commands that make an agent certificate
(on the command line: the UI never serves keys). It then gives SDK
snippets and waits, live, for the first trace from an agent other than
`demo-agent`, linking to it when it arrives. You can hide it from the page.
`CONDUIT_FROM_SOURCE=1 quickstart.sh` builds the gateway, collector and UI
from your checkout instead of pulling the release, one image at a time so
it fits a 2 GiB Docker VM.
`quickstart.sh status` reprints the login, `down` stops the stack, and
`reset` deletes its data and credentials. `scripts/quickstart-check.sh` is
the CI check: from a cold start, the first trace must be visible in the UI
within 300 seconds.
The quickstart is for local evaluation only. Its CA key sits next to the
certificates it signs; use real PKI (below) for anything shared.
## Local: docker compose (from source)
For working on Conduit itself, this stack builds the images from your
checkout.
```bash
./scripts/gen-certs.sh # dev CA, gateway cert, example agent cert
./scripts/gen-certs.sh agent my-agent # more agents as needed
# UI login (see security.md for other auth modes)
printf '%s' 'a-long-dev-password' | go run ./cmd/ui hash-password \
| sed 's/^/admin:/' > deployments/docker/ui-users
export CLICKHOUSE_PASSWORD=$(openssl rand -hex 16)
docker compose -f deployments/docker/docker-compose.yml up --build
```
Then open http://localhost:8080 and sign in as `admin`. Agents connect to
`localhost:4317` using `certs/ca.crt` and their own client cert (see
[sdk-go.md](sdk-go.md)).
To check the whole pipeline end to end, run
`UI_USER=admin UI_PASSWORD=… ./scripts/smoke-test.sh` with the same
`CLICKHOUSE_PASSWORD` exported. It sends a trace with the Go SDK and checks
that it reaches ClickHouse, VictoriaMetrics, Tempo and the UI. CI runs it on
every push.
Compose sets `UI_COOKIE_INSECURE=true` because it serves plain HTTP on
localhost. Never set it anywhere else. Without `UI_SESSION_KEY`, sessions
end whenever the UI restarts.
## Kubernetes
The manifests in `deployments/k8s/` assume a `conduit` namespace,
[cert-manager](https://cert-manager.io), and NATS, ClickHouse,
VictoriaMetrics and Tempo reachable at the hostnames in `configmap.yaml`.
### 1. Certificates (`mtls-cert.yaml`)
The file creates a root CA, `conduit-ca-issuer`, a gateway server cert
(secret `conduit-mtls-certs`) and an example agent cert. The gateway and
the agents all chain to that one CA. The gateway Deployment maps the
secret's `tls.crt`, `tls.key` and `ca.crt` onto `server.crt`, `server.key`
and `ca.crt`.
For production, swap the bootstrap self-signed root CA for your PKI but
keep the issuer name. Issue one `Certificate` per agent with
`usages: [client auth]`. Its `commonName` becomes the agent's identity.
### 2. Secrets
Create `conduit-secrets` in the `conduit` namespace:
| Key | Used by | Value |
|-----|---------|-------|
| `clickhouse-dsn` | collector, ui | `clickhouse://user:pass@host:9000/observability` |
| `ui-users` | ui (login mode) | lines of `username: