{
  "markdown": "# Fanout\n\n**Single-binary, agent-native OpenTelemetry investigation.**\n\n[![CI](https://github.com/labstack/fanout/actions/workflows/ci.yml/badge.svg)](https://github.com/labstack/fanout/actions/workflows/ci.yml)\n[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)\n[![Go Reference](https://pkg.go.dev/badge/github.com/labstack/fanout.svg)](https://pkg.go.dev/github.com/labstack/fanout)\n\nFanout ingests OpenTelemetry data, durably publishes it as atomic Parquet\nbatches, and puts an AI agent in front of it — in one Go process with no\nexternal dependencies to operate. Persistent trace indexes serve targeted\nreads; embedded DuckDB handles SQL, broad scans, and rebuildable rollups. Point\nan SDK or Collector at it, open the browser, and ask questions about your\ntelemetry in plain language.\n\nThere is no separate ingester, query service, metadata database, object store,\nor dashboard server to deploy. One binary, one data directory.\n\n## Architecture\n\nOne process owns ingest, storage, query, alerting, the agent runtime, and an\nMCP server. Everything below the dashed boundary is compiled into a single\nexecutable, including the React client.\n\n![Fanout architecture](docs/diagrams/architecture.svg)\n\nTelemetry lands over OTLP/gRPC or OTLP/HTTP. Concurrent small requests may\nshare a group-commit batch, while up to four workers independently encode and\ndurably publish atomic Parquet directories with persistent trace indexes.\nTargeted trace reads go through those indexes; DuckDB scans the same Parquet\nfor SQL and maintains rebuildable service, endpoint, and edge rollups. The\nbrowser client, an in-process agent, and any\nexternal MCP host all reach the same typed observability contract rather than\nissuing raw SQL.\n\nParquet is authoritative telemetry, DuckDB query state is rebuildable, and\nSQLite is reserved for transactional product state. Native compaction\nprepares replacements while reads continue and briefly gates readers only for\nthe crash-safe namespace swap:\n\n![Fanout persistence](docs/diagrams/persistence.svg)\n\nApplication state (users, sessions, dashboards, alert rules, agent threads)\nlives in the control SQLite database and never sits on the telemetry write\npath. The published Parquet directories are self-describing, so startup can\ndiscover the authoritative batch set directly from the filesystem.\n\n## Performance\n\nThe independent [Fanout Bench](https://github.com/labstack/fanout-bench)\nproject measures authenticated ingest and optional dashboard read load against\nyour hardware. It uses the official OpenTelemetry generator and publishes raw,\nreproducible evidence separately from the production binary. Ingest, indexed\nreads, DuckDB analytics, and native Parquet maintenance have separate\ncoordination paths but still compete for the same CPU, memory bandwidth,\nfilesystem cache, and disk.\n\nThe current publication candidate is **296,196 accepted OpenTelemetry items per\nsecond** sustained for five minutes with traces, logs, and metrics arriving\ntogether on a machine with eight logical CPUs and 15.6 GiB of memory. It is a\nsingle run, and the benchmark harness that produced it carried uncommitted\nlocal changes, so it is not Fanout's official headline yet. The [performance methodology](https://fanout.run/explanation/performance)\nshows the signal breakdown, quality gates, limitations, and publication bar.\n\n## How it compares\n\nFanout is a single node holding traces, logs, and metrics for a system you can\nreason about from one place. That premise, rather than any single feature, is\nwhat separates it from its neighbours.\n\n| If you use | Where Fanout differs |\n| --- | --- |\n| **Grafana with Loki, Tempo, and Mimir** | That stack keeps a service and a query language per signal, plus object storage underneath. Fanout keeps one process, one data directory, and one typed contract across all three signals, at the cost of the horizontal scale those components are built for. |\n| **SigNoz** | Both are OTLP-native and self-hosted. SigNoz composes a collector, ClickHouse, and query services; Fanout compiles ingest, authoritative Parquet storage, indexed trace reads, DuckDB analytics, alerting, and the browser client into one binary. |\n| **Jaeger** | Jaeger covers traces and expects a storage backend you run separately. Fanout ingests traces, logs, and metrics into the same store, with nothing else to deploy. |\n| **Prometheus with Grafana** | Prometheus pulls metrics and is excellent at them. Fanout accepts pushed OTLP for all three signals and is built around investigating a specific incident rather than maintaining long-range metric series. |\n| **Datadog**, **Honeycomb**, **Grafana Cloud** | Those are managed services: someone else runs the storage, the scaling, and the upgrades, and your telemetry leaves your network to get there. Fanout is a binary you run, on data that stays on your disk. |\n| **An OpenTelemetry Collector piped into ClickHouse** | The same shape, assembled by hand: collector, database, dashboards, and the glue between them. Fanout is that assembly as one program, with an agent and an MCP server already wired to the same query contract. |\n\nFanout is a single node. It has no clustering, no replication, and no object\ntier; a deployment that outgrows one machine's disk and CPU has outgrown\nFanout.\n\n## Requirements\n\n- **Go and a C compiler** with `CGO_ENABLED=1` — DuckDB is a cgo dependency\n- **[Bun](https://bun.sh)** — compiles the browser assets\n- **[just](https://just.systems)** — task runner\n- A 32-character **authentication code secret**\n\nSMTP and an AI provider are optional. Without SMTP, an operator can mint a\nshort-lived login link from the local Fanout binary. Without an AI key, ingest,\ndashboards, traces, logs, metrics, and MCP continue to work; only investigation\nchat and AI-assisted controls are hidden.\n\n## Quick start\n\n### Native binary\n\nRelease archives support Linux and macOS on amd64 and arm64. The installer\nverifies the selected archive against the release checksum before extracting:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/labstack/fanout/main/scripts/install.sh | sh\n```\n\nSet `FANOUT_VERSION=v{YYYY.M}.{N}` to pin a release and `FANOUT_PREFIX` to\nchoose the installation directory.\n\n### Docker\n\n```sh\ndocker run --name fanout -p 7520:7520 -p 4317:4317 -p 4318:4318 \\\n  -v fanout-data:/var/lib/fanout/data \\\n  -e FANOUT_AUTH_CODE_SECRET=$(openssl rand -hex 32) \\\n  labstack/fanout:latest\n```\n\nOpen the one-time setup URL printed by the container and create the first\nadministrator. Fanout displays the ingest token exactly once; save it with\nyour collector secrets. A standard OTLP/HTTP exporter can then use:\n\n```sh\nexport OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf\nexport OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318\nexport OTEL_EXPORTER_OTLP_HEADERS=\"authorization=Bearer%20$INGEST_TOKEN\"\n```\n\nFor a Collector on the same private container network, the forwarding side is:\n\n```yaml\nexporters:\n  otlp_http/fanout:\n    endpoint: http://fanout:4318\n    headers:\n      Authorization: \"Bearer ${env:INGEST_TOKEN}\"\n\nservice:\n  pipelines:\n    traces:  { receivers: [otlp], exporters: [otlp_http/fanout] }\n    metrics: { receivers: [otlp], exporters: [otlp_http/fanout] }\n    logs:    { receivers: [otlp], exporters: [otlp_http/fanout] }\n```\n\nThis assumes the Collector's existing `otlp` receiver and an environment\nvariable containing the one-time token. Replace `fanout` with the private\nhostname reachable from that Collector.\n\nThe equivalent OTLP/gRPC endpoint is `localhost:4317`. For later sign-in\nwithout SMTP, mint a 15-minute, single-use link against the running\ncontainer's control database:\n\n```sh\ndocker exec fanout fanout --config /etc/fanout/fanout.yaml \\\n  login-link admin@example.com\n```\n\nAdd `FANOUT_AI_API_KEY` to enable chat. Configure all four SMTP settings\n(`FANOUT_SMTP_HOST`, `FANOUT_SMTP_USERNAME`, `FANOUT_SMTP_PASSWORD`, and\n`FANOUT_SMTP_FROM`) to enable email-code login.\n\nThe distroless image runs unprivileged as UID 65532. A bind-mounted host directory at\n`/var/lib/fanout/data` must be writable by that user; a named volume, as above,\nneeds no such handling.\n\nThe image selects `/etc/fanout/fanout.yaml` by default. That file contains only\nthe container listener and data-directory defaults; it does not contain\ncredentials. Start a container-specific document from that file so it retains\nthe externally reachable HTTP and OTLP bind addresses:\n\n```sh\ncp fanout.docker.yaml fanout.yaml\n# Add the remaining settings, then mount it over the image document:\ndocker run -v ./fanout.yaml:/etc/fanout/fanout.yaml:ro \\\n  labstack/fanout:latest\n```\n\nA replacement document must set `server.http_addr: \":7520\"`,\n`ingest.otlp_grpc_addr: \":4317\"`, and `ingest.otlp_http_addr: \":4318\"`;\nomitting either ingest address restores its secure loopback-only built-in\ndefault, which is unreachable through a published container port.\n\n### From source\n\n```sh\ngit clone https://github.com/labstack/fanout.git\ncd fanout\njust install   # browser dependencies and git hooks\njust build     # browser assets, then the binaries\n```\n\nRun it with the minimum configuration:\n\n```sh\nexport FANOUT_AUTH_CODE_SECRET=$(openssl rand -hex 32) # must be 32+ characters\n\n./bin/fanout\n```\n\nOptionally set `FANOUT_AI_API_KEY` for chat and the SMTP variables shown above\nfor email-code login. Without SMTP, run `./bin/fanout login-link\nadmin@example.com` from the same configuration and data directory.\n\nFanout serves the UI on <http://localhost:7520>, accepts OTLP/gRPC on\n`127.0.0.1:4317`, and accepts OTLP/HTTP on `127.0.0.1:4318`. The first account\ncreated becomes the administrator and receives the ingest token once.\n\nPoint any OpenTelemetry collector or SDK at either OTLP endpoint with the\ningest token. Use the separate\n[Fanout Bench](https://github.com/labstack/fanout-bench) project for controlled\ncapacity tests.\n\n## Configuration\n\nConfiguration is resolved once at startup and validated before Fanout opens\ndata files or listeners. Sources apply in this order: built-in defaults, an\noptional YAML document selected with `--config`, then `FANOUT_` environment\nvariables. Fanout does not search for configuration or load `.env` files.\n\n[`fanout.example.yaml`](fanout.example.yaml) is the complete commented schema:\n\n```sh\ncp fanout.example.yaml fanout.yaml\n./bin/fanout --config ./fanout.yaml\n```\n\nEnvironment variables override the corresponding YAML values and are useful\nfor container injection and secrets. An empty environment value means \"no\noverride\"; use YAML for an explicit empty string. Unknown YAML keys and unknown\n`FANOUT_` variables are startup errors, except for the service-discovery names\nKubernetes and Docker link-style networking inject for a Service named\n`fanout`. All environment variables outside the `FANOUT_` namespace are\nignored.\n\nYAML null values are rejected. Boolean values must use YAML 1.2 `true` or\n`false` (not `yes`, `no`, `on`, or `off`). If a YAML document contains a\ncredential, Fanout requires that the file not be accessible by group or others\n(for example, mode `0600`). The definitions live in\n[`internal/config/config.go`](internal/config/config.go); the settings most\noperators touch are:\n\n| YAML key | Environment override | Default | Purpose |\n| --- | --- | --- | --- |\n| `server.http_addr` | `FANOUT_HTTP_ADDR` | `:7520` | UI, API, and MCP listener |\n| `ingest.otlp_grpc_addr` | `FANOUT_OTLP_GRPC_ADDR` | `127.0.0.1:4317` | OTLP/gRPC ingest listener |\n| `ingest.otlp_http_addr` | `FANOUT_OTLP_HTTP_ADDR` | `127.0.0.1:4318` | OTLP/HTTP ingest listener |\n| `storage.data_dir` | `FANOUT_DATA_DIR` | `./data` | Parquet, query state, and control SQLite |\n| `auth.mode` | `FANOUT_AUTH_MODE` | `local` | `local` (login link or SMTP) or `oidc` |\n| `auth.code_secret` | `FANOUT_AUTH_CODE_SECRET` | — | Required in local mode, 32+ characters |\n| `ai.provider` | `FANOUT_AI_PROVIDER` | `anthropic` | `anthropic` or `openai` |\n| `ai.api_key` | `FANOUT_AI_API_KEY` | — | Enables AI investigation chat |\n| `storage.retention_days` | `FANOUT_RETENTION_DAYS` | `30` | Telemetry retention window |\n| `mcp.enabled` | `FANOUT_MCP_ENABLED` | `true` | Serve the MCP endpoint at `/mcp` |\n\n### Advanced DuckDB sizing\n\nMost deployments should not set DuckDB variables. Give the process or\ncontainer the CPU and memory limits it may use; at startup Fanout reserves\nheadroom for Go and sizes the DuckDB connection pool from available CPUs. The\nresolved values and whether Fanout chose them are available in the\n`runtime_sizing` block returned by `/readyz` and `/api/health`.\n\nThese variables are escape hatches for measured, unusual workloads:\n\n| Variable | Automatic behavior | When to override |\n| --- | --- | --- |\n| `storage.duckdb.memory` / `FANOUT_DUCKDB_MEMORY` | 60% of the container or host memory available to Fanout | A measured co-tenant workload needs a different Go/DuckDB split |\n| `storage.duckdb.max_connections` / `FANOUT_DUCKDB_MAX_CONNECTIONS` | Available Go CPUs, bounded to 2–16 connections | Query concurrency has been benchmarked for this machine |\n| `storage.duckdb.threads` / `FANOUT_DUCKDB_THREADS` | DuckDB chooses its own query worker count | Query-heavy work must leave specific cores free for ingest |\n\nAn explicit value always wins. Startup logs and `/readyz` report the detected\nhost and cgroup limits, the selected source, and whether detection was\nincomplete. If Fanout cannot conclusively inspect a container limit, it warns;\nset `storage.duckdb.memory` or `FANOUT_DUCKDB_MEMORY` for a guaranteed bound.\n\nFor TLS and reverse proxies, health checks, backups and restores, upgrades,\nretention, and recovery, see the [operator runbook](docs/operations.md).\n\n## Development\n\n```sh\njust            # list every recipe\njust check      # the full gate: format, lint, asset freshness, and tests\njust test       # Go tests\njust ui         # rebuild the embedded browser assets\n```\n\nThe browser workspaces build **into** `internal/ui/dist` and\n`internal/mcp/apps`, and those outputs are committed because `go:embed` needs\nthem present in a source checkout. The binary is therefore only ever as fresh\nas the last UI build, so `just ui-check` rebuilds both workspaces and fails if\nthe committed bytes no longer match. It is part of `just check` and runs in CI.\n\n[Lefthook](https://lefthook.dev) runs formatting and linting on commit and the\nfull gate on push; `just install` wires it up. The pre-push hook is a\nconvenience and lefthook may skip it when it detects no changed files — CI runs\nthe same `just check` unconditionally, and that is what actually enforces it.\n\n## Project layout\n\n```text\ncmd/fanout/        process composition and the single entry point\ninternal/          ingest, storage, query, agent, MCP, auth, alerts\nui/host/           React AG-UI browser host (build-time)\nui/apps/           portable React MCP Apps (build-time)\ndocs/diagrams/     d2 sources and rendered SVG\n```\n\n## Releases\n\nVersions are CalVer — `v{YYYY.M}.{N}`, numbered from 0 within each month, so\n`v2026.8.1` is the second release of August 2026. Pushing a tag publishes the\nsame release manifest to Docker Hub and GHCR and moves both `latest` tags.\nGHCR remains the canonical registry and also carries development images:\n\n| Image tag | Points at |\n| --- | --- |\n| `labstack/fanout:latest` | the newest release, mirrored on Docker Hub |\n| `labstack/fanout:2026.8.0` | that exact release, mirrored on Docker Hub |\n| `ghcr.io/labstack/fanout:latest` | the newest release |\n| `ghcr.io/labstack/fanout:2026.8.0` | that exact release |\n| `ghcr.io/labstack/fanout:main` | the tip of `main` |\n| `ghcr.io/labstack/fanout:sha-<commit>` | one specific commit |\n\nRelease images are multi-architecture for `linux/amd64` and `linux/arm64`.\nRelease archives provide Linux and macOS binaries for amd64 and arm64; every\nartifact is built on a native runner because DuckDB requires cgo.\nThe complete release and verification contract is in\n[docs/release.md](docs/release.md).\n\n## Contributing\n\nIssues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).\nRun `just check` and `just test-race` before opening a pull request; together\nthey match CI. For anything security-related, follow [SECURITY.md](SECURITY.md)\ninstead of opening an issue.\n\n## Scope\n\nThis repository is Fanout itself, and it builds to a working binary with no\nother repository involved. Not included: LabStack's own deployment\nconfiguration, uptime monitoring, and public demo instance. Those describe how\nwe operate Fanout, not what it does.\n\nThe documentation site in [`site/`](site) is included, and is the exception that\nproves the rule: it is not marketing copy but the product's own reference, and\npart of it is generated. `cmd/fanout-docgen` writes every settings page from the\nsame `internal/config` type the loader binds, and `just check` fails when a\ncommitted page no longer matches it. Documentation that can drift from the\nbinary is documentation that eventually lies about it, and the only place that\ncheck can run is next to the code it checks.\n\n## License\n\n[Apache-2.0](LICENSE) © LabStack LLC. See [NOTICE](NOTICE) and\n[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for attribution, and\n[TRADEMARK](TRADEMARK.md) for use of the Fanout name and logo.\n",
  "bytes": 17228,
  "sha": "61487c80c62d9f8780458efa1a49b44d03bc941e59d3e3c9730aff4ebc2e1a15",
  "repo_slug": "labstack/fanout",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_labstack_fanout_5d64c897/readme"
}