Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/site.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Publishing the documentation site.
#
# The reference under site/src/content/docs/reference/settings is generated from
# The reference under site/src/content/docs/docs/reference is generated from
# internal/config and committed, and `just check` fails a change that edits one
# without regenerating. That gate is worth nothing if the published site is not
# the gated tree, so publication is something the repository does rather than
Expand Down
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Fanout's built-in agent or connect your own over MCP.
Fanout keeps OpenTelemetry traces, logs, and metrics in one binary and one data directory, with no
external storage services to operate. Fanout is pre-release and changes without migration paths; the browser
client, built-in agent, MCP tools, dashboards, and alerts are preview capabilities
([status](https://fanout.run/status/capabilities)). Start with the
([status](https://fanout.run/docs/reference/capabilities)). Start with the
[documentation](https://fanout.run) or run it below.

- OTLP ingest over gRPC and HTTP, with local Parquet storage and DuckDB analytics.
Expand Down Expand Up @@ -46,8 +46,8 @@ docker exec fanout fanout login-link admin@example.com
```

The UI, API, MCP, and both OTLP transports share port `7520`. See
[installation](https://fanout.run/start/install) for image defaults and bind-mount ownership, and
[first boot](https://fanout.run/start/first-boot) for setup and SMTP login.
[installation](https://fanout.run/docs/get-started/install) for image defaults and bind-mount ownership, and
[first boot](https://fanout.run/docs/get-started/first-boot) for setup and SMTP login.

### Native binary

Expand Down Expand Up @@ -91,22 +91,22 @@ service:

Use an existing `otlp` receiver, set `INGEST_TOKEN`, and replace `fanout` with the Collector's
reachable private hostname. OTLP/gRPC uses `localhost:7520` locally; see [send
telemetry](https://fanout.run/start/send-telemetry) for TLS and HTTP/2 setup.
telemetry](https://fanout.run/docs/get-started/send-telemetry) for TLS and HTTP/2 setup.

### Enable chat

Select `FANOUT_AI_PROVIDER=anthropic` and inject `ANTHROPIC_API_KEY`, or select
`FANOUT_AI_PROVIDER=openai` and inject `OPENAI_API_KEY`, then restart Fanout; the selected provider
requires its key at startup. With no provider, chat is disabled and the other features still work.
See [AI settings](https://fanout.run/reference/settings/ai).
See [AI settings](https://fanout.run/docs/reference/configuration/ai).

### Connect an MCP client

Set `FANOUT_PUBLIC_URL` to your stable, externally reachable HTTPS origin, then point a remote MCP
client at that origin's `/mcp` endpoint. The client discovers and registers through OAuth; you
authorize its scopes in the browser. Fanout serves MCP `2026-07-28` and, through the `initialize`
handshake, `2025-11-25` and `2025-06-18` over Streamable HTTP. See [connect over
MCP](https://fanout.run/guides/connect-over-mcp) for setup and permissions.
MCP](https://fanout.run/docs/guides/mcp) for setup and permissions.

## How it compares

Expand All @@ -130,7 +130,7 @@ and agent clients share a typed observability contract.
![Fanout runtime architecture: OTLP ingest and commit workers publish Parquet; the browser API, agent runtime, and MCP server share queries and dashboards, with DuckDB query state, SQLite control state, anomaly detection, and webhook alerts.](docs/diagrams/architecture.svg)

Parquet holds telemetry, DuckDB query state is rebuildable, and SQLite holds transactional product
state. See [how storage works](https://fanout.run/explanation/storage-model) for durable ingest,
state. See [how storage works](https://fanout.run/docs/concepts/storage) for durable ingest,
indexed reads, and compaction while reads continue.

![Telemetry durability and query flow: authenticated OTLP rows pass through group admission, parallel commit workers, and durable publication into immutable Parquet batches; indexed trace reads, DuckDB SQL, retention, and compaction use those batches, while service and edge rollups populate rebuildable query state.](docs/diagrams/persistence.svg)
Expand All @@ -154,13 +154,13 @@ change requires a restart.
| `FANOUT_MCP_ENABLED` | `true` | Serve the MCP endpoint at `/mcp` |

Every secret accepts `<NAME>_FILE` instead of its value; see the [settings
reference](https://fanout.run/reference/configuration) for validation and [runtime secret
reference](https://fanout.run/docs/reference/configuration) for validation and [runtime secret
delivery](docs/operations.md#runtime-secret-delivery) for deployment examples.

### Advanced DuckDB sizing

Fanout sizes DuckDB automatically; measured workloads can override memory, connections, and threads
as described in [DuckDB sizing](https://fanout.run/reference/configuration#duckdb-sizing).
as described in [DuckDB sizing](https://fanout.run/docs/reference/configuration#duckdb-sizing).

For TLS, health checks, backups, upgrades, retention, and recovery, see the [operator
runbook](docs/operations.md).
Expand All @@ -170,7 +170,7 @@ runbook](docs/operations.md).
Fanout v2026.10.8-alpha accepted **415,859 mixed OpenTelemetry items/s**
over 303.5 seconds on a Hetzner Cloud ccx33 (8 dedicated vCPUs, 32 GB RAM), with
telemetrygen on a separate ccx33. The ingest run on 16 October 2026 passed every
quality gate. See the [performance methodology](https://fanout.run/explanation/performance).
quality gate. See the [performance methodology](https://fanout.run/docs/concepts/performance).
[Fanout Bench](https://github.com/labstack/fanout-bench) reproduces the measurement on your hardware.

## Build from source
Expand Down
13 changes: 6 additions & 7 deletions cmd/fanout-docgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func main() {
// list going stale is loud rather than a quietly shorter table.
routeDirs = flag.String("route-dirs", "internal/api,internal/agent,cmd/fanout",
"comma-separated directories whose files register HTTP routes")
outDir = flag.String("out", "site/src/content/docs/reference", "reference root to write generated pages into")
outDir = flag.String("out", "site/src/content/docs/docs/reference", "reference root to write generated pages into")
envExample = flag.String("env-example", ".env.example", "path to the generated environment template")
check = flag.Bool("check", false, "exit non-zero when a written page differs from the one on disk")
)
Expand All @@ -77,8 +77,7 @@ func run(source, alertSource, routeDirs, outDir, envExample string, check bool)

// The alert expression environment is the same shape of problem as the
// configuration: a tagged struct whose field comments are the documentation.
// It lands beside the settings pages so the sidebar's autogenerated group
// picks it up without this file naming it.
// It lands in the reference root, where the sidebar lists it explicitly.
vars, err := collectAlertEnv(alertSource)
if err != nil {
return err
Expand All @@ -99,7 +98,7 @@ func run(source, alertSource, routeDirs, outDir, envExample string, check bool)
if err != nil {
return err
}
pages["http-routes.mdx"] = renderRoutes(routes)
pages["api.mdx"] = renderRoutes(routes)

// The role/capability matrix, from the same map the middleware consults.
// It was hand-written until a review found it claiming `viewer` could not
Expand All @@ -116,7 +115,7 @@ func run(source, alertSource, routeDirs, outDir, envExample string, check bool)
if err != nil {
return err
}
pages["mcp-tools.mdx"] = toolsPage
pages["mcp.mdx"] = toolsPage

outputs := map[string][]byte{envExample: renderEnvironment(fields)}
for name, body := range pages {
Expand Down Expand Up @@ -267,7 +266,7 @@ func renderAlertEnv(vars []field) []byte {
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", v.Key, v.Type, doc)
}

b.WriteString("\nSee [Set up alerts](/guides/set-up-alerts) for the rule model these sit in,\n")
b.WriteString("\nSee [Set up alerts](/docs/guides/alerts) for the rule model these sit in,\n")
b.WriteString("including the pending/firing/resolved lifecycle and webhook delivery.\n")

return []byte(b.String())
Expand Down Expand Up @@ -530,7 +529,7 @@ func render(fields []field) (map[string][]byte, error) {
name,
)
}
pages["settings/"+name+".mdx"] = renderPage(name, page, group)
pages["configuration/"+name+".mdx"] = renderPage(name, page, group)
}
return pages, nil
}
Expand Down
16 changes: 8 additions & 8 deletions cmd/fanout-docgen/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func TestRenderProducesOnePageWithFrontmatterPerGroup(t *testing.T) {

for name, body := range pages {
text := string(body)
if strings.Contains(text, "Fanout also recognizes") != (name == "settings/ai.mdx") {
if strings.Contains(text, "Fanout also recognizes") != (name == "configuration/ai.mdx") {
t.Errorf("%s: vendor-variable paragraph belongs only on the AI settings page", name)
}
if !strings.HasPrefix(text, "---\n") {
Expand Down Expand Up @@ -175,7 +175,7 @@ func TestSecretSettingsCarryTheirWarning(t *testing.T) {
}

for group := range secretGroups {
body := string(pages["settings/"+group+".mdx"])
body := string(pages["configuration/"+group+".mdx"])
if !strings.Contains(body, "· secret") {
t.Errorf("%s.mdx: holds a secret setting but does not mark it in the table", group)
}
Expand All @@ -199,30 +199,30 @@ func TestFlowDropsTheGodocFieldNamePrefix(t *testing.T) {

func TestOrphanedFindsGeneratedPagesOnly(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "settings"), 0o755); err != nil {
if err := os.MkdirAll(filepath.Join(dir, "configuration"), 0o755); err != nil {
t.Fatal(err)
}

generated := "---\ntitle: x\ngenerated: true\n---\n"
authored := "---\ntitle: x\n---\n"

files := map[string]string{
"settings/server.mdx": generated, // still generated, keep
"settings/gone.mdx": generated, // no longer generated, remove
"roles.mdx": authored, // hand-written, never touch
"configuration/server.mdx": generated, // still generated, keep
"configuration/gone.mdx": generated, // no longer generated, remove
"roles.mdx": authored, // hand-written, never touch
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

got, err := orphaned(dir, map[string][]byte{"settings/server.mdx": nil})
got, err := orphaned(dir, map[string][]byte{"configuration/server.mdx": nil})
if err != nil {
t.Fatalf("orphaned: %v", err)
}

want := filepath.Join(dir, "settings/gone.mdx")
want := filepath.Join(dir, "configuration/gone.mdx")
if len(got) != 1 || got[0] != want {
t.Fatalf("orphaned = %v, want just %q", got, want)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/fanout-docgen/mcptools.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func renderMCPTools() ([]byte, error) {
b.WriteString("\n")
}
b.WriteString("## Authentication\n\n")
b.WriteString("MCP uses OAuth tokens bound to the instance’s resource URI.\n[Connect an agent](/guides/connect-over-mcp#scopes) documents protocol\nrequirements and delegated scopes. Dashboard tools operate on the authenticated\nuser’s own dashboards and require the account capability\n`dashboards:manage-own`; see [roles](/reference/roles).\n\n## Other interfaces\n\nUse OTLP to write telemetry. Configuration uses environment variables, and user\nand alert management use the [HTTP API](/reference/http-routes).\n")
b.WriteString("MCP uses OAuth tokens bound to the instance’s resource URI.\n[Connect an agent](/docs/guides/mcp#scopes) documents protocol\nrequirements and delegated scopes. Dashboard tools operate on the authenticated\nuser’s own dashboards and require the account capability\n`dashboards:manage-own`; see [roles](/docs/reference/roles).\n\n## Other interfaces\n\nUse OTLP to write telemetry. Configuration uses environment variables, and user\nand alert management use the [HTTP API](/docs/reference/api).\n")

toolCount = tools
return []byte(b.String()), nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/fanout-docgen/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func renderRoles() ([]byte, error) {
b.WriteString("In `oidc` mode it depends on configuration. If `FANOUT_OIDC_ADMIN_GROUPS` or\n")
b.WriteString("`FANOUT_OIDC_OPERATOR_GROUPS` is set, the provider owns every linked user's\n")
b.WriteString("role and reapplies it on each login; otherwise roles stay local.\n")
b.WriteString("[Authentication modes](/explanation/authentication-modes) covers the details,\n")
b.WriteString("[Authentication modes](/docs/concepts/authentication) covers the details,\n")
b.WriteString("including why the last active administrator is never demoted.\n\n")

b.WriteString(":::caution[A role is not a data boundary]\n")
Expand Down
6 changes: 3 additions & 3 deletions cmd/fanout-docgen/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func renderRoutes(routes []api.RouteDoc) []byte {
b.WriteString("Every route below is served on `FANOUT_ADDR` (`127.0.0.1:7520` by default;\n")
b.WriteString("the image sets `:7520`).\n")
b.WriteString("OTLP shares this listener but uses its own handlers and ingest credential; see\n")
b.WriteString("[send your first telemetry](/start/send-telemetry).\n\n")
b.WriteString("[send your first telemetry](/docs/get-started/send-telemetry).\n\n")

b.WriteString("## Conditional routes\n\nThe table includes every route the binary can register. These routes depend on\nconfiguration:\n\n")

Expand All @@ -263,11 +263,11 @@ func renderRoutes(routes []api.RouteDoc) []byte {
b.WriteString("pattern Echo matches on, not a literal URL.\n")
b.WriteString("\n")

b.WriteString("## Interface stability\n\nThe `/api/*` routes serve the browser client and can change without migration\npaths. [MCP](/guides/connect-over-mcp) is the agent interface. OTLP ingest and\n[operational endpoints](/reference/endpoints) have their own contracts.\n\nManage alert rules through the [HTTP API](/guides/set-up-alerts). Administrators\nrotate ingest tokens on **Connect telemetry** or with\n`POST /api/settings/ingest/token/rotate`.\n\n")
b.WriteString("## Interface stability\n\nThe `/api/*` routes serve the browser client and can change without migration\npaths. [MCP](/docs/guides/mcp) is the agent interface. OTLP ingest and\n[operational endpoints](/docs/reference/endpoints) have their own contracts.\n\nManage alert rules through the [HTTP API](/docs/guides/alerts). Administrators\nrotate ingest tokens on **Connect telemetry** or with\n`POST /api/settings/ingest/token/rotate`.\n\n")

b.WriteString("## Errors\n\nApplication API errors, including unknown routes and wrong methods, return objects shaped like\n`{\"code\":\"bad_request\",\"message\":\"Safe explanation\"}`. Codes are stable\nsnake_case identifiers; clients branch on `code` rather than message text.\nInvalid dashboard or panel specs also include `problems` with field paths,\nmessages and optional hints. Every error response uses `Cache-Control: no-store`;\nHEAD errors have no body. Server failures return a short, safe message; the\nunderlying error is logged and never returned.\n\nDefault codes by HTTP status:\n\n| Status | Code |\n|---|---|\n| 400 | `bad_request` |\n| 401 | `unauthorized` |\n| 403 | `forbidden` |\n| 404 | `not_found` |\n| 405 | `method_not_allowed` |\n| 409 | `conflict` |\n| 413 | `payload_too_large` |\n| 415 | `unsupported_media_type` |\n| 422 | `unprocessable` |\n| 429 | `rate_limited` |\n| 500 | `internal` |\n| 502 | `bad_gateway` |\n| 503 | `unavailable` |\n| 504 | `timeout` |\n\nOther 4xx statuses use `bad_request`; other 5xx statuses use `internal`.\nSpecific conditions use `already_current`, `dashboard_version_not_found`,\n`dashboard_version_conflict`, `invalid_spec` and `last_active_administrator`.\nTool failures carry `error: {code, message}` inside the tool result.\nPanel execution results keep their own status and error fields.\nOAuth, OTLP/gRPC, MCP JSON-RPC and AG-UI run errors keep their protocol spelling.\nOperational responses at `/healthz`, `/readyz` and `/metrics` keep their bodies.\n\n")

b.WriteString("The **Requires** column comes from the authorization middleware for each method\nand path. [Roles](/reference/roles) maps capabilities to roles.\n\n")
b.WriteString("The **Requires** column comes from the authorization middleware for each method\nand path. [Roles](/docs/reference/roles) maps capabilities to roles.\n\n")

b.WriteString("| Method | Path | Requires |\n")
b.WriteString("|---|---|---|\n")
Expand Down
2 changes: 1 addition & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Authentication and access operations

Operator reference for running Fanout's authentication safely. Settings are
environment variables; see the [settings reference](https://fanout.run/reference/configuration).
environment variables; see the [settings reference](https://fanout.run/docs/reference/configuration).

## First run

Expand Down
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ their HTTP `Host` matches that origin, so the reverse proxy must preserve the
original host.

MCP also validates `Origin` before authentication; see
[Connect an agent over MCP](https://fanout.run/guides/connect-over-mcp#protocol).
[Connect an agent over MCP](https://fanout.run/docs/guides/mcp#protocol).

## Health and monitoring

Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ evidence. Unknown cost stops further paid calls pending settlement.
## Documentation checks

Dashboard workflow prose lives in
`site/src/content/docs/guides/build-dashboards.mdx`. HTTP, MCP and role references
`site/src/content/docs/docs/guides/dashboards.mdx`. HTTP, MCP and role references
are generated from the server; change their generator sources when necessary,
then regenerate and check:

Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func DefaultSpec() panel.Dashboard {
}

func receiverDescription(receiver, description string) string {
return description + " Supplied by the Collector `" + receiver + "` receiver. [Watch hosts, containers and endpoints](https://fanout.run/start/send-telemetry#watch-hosts-containers-and-endpoints)."
return description + " Supplied by the Collector `" + receiver + "` receiver. [Collector setup](https://fanout.run/docs/get-started/send-telemetry#watch-hosts-containers-and-endpoints)."
}

func capacityPanel(id, title, receiver, description, sql string) panel.Panel {
Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/defaults_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func TestDefaultSpecKeepsExistingPanelsBeforeSystemSections(t *testing.T) {
var ids []string
for i, p := range spec.Panels {
ids = append(ids, p.ID)
if i >= 8 && (!strings.Contains(p.Description, "Collector `") || !strings.Contains(p.Description, "https://fanout.run/start/send-telemetry#watch-hosts-containers-and-endpoints")) {
if i >= 8 && (!strings.Contains(p.Description, "Collector `") || !strings.Contains(p.Description, "https://fanout.run/docs/get-started/send-telemetry#watch-hosts-containers-and-endpoints")) {
t.Fatalf("receiver guidance missing: %+v", p)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/dashboard_scope_docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func TestDashboardScopeDocsMatchCatalog(t *testing.T) {
raw, err := os.ReadFile("../../site/src/content/docs/guides/connect-over-mcp.mdx")
raw, err := os.ReadFile("../../site/src/content/docs/docs/guides/mcp.mdx")
if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/telemetry/store/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func Open(root string) (*Repository, error) {
// the other saying something the code no longer guarantees. Guidance an
// operator can act on wrongly is worse than guidance in one place they have to
// open. TestUnresolvedCompactionRunbookExists keeps the link honest.
const compactionRunbookURL = "https://fanout.run/guides/troubleshoot#an-unresolved-compaction-blocks-startup"
const compactionRunbookURL = "https://fanout.run/docs/guides/troubleshooting#an-unresolved-compaction-blocks-startup"

// logUnresolvedCompaction spells out the operator's options for a marker that
// blocks startup.
Expand Down
2 changes: 1 addition & 1 deletion internal/telemetry/store/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ func TestRepositoryOpenRejectsUnsafeCompactionMarker(t *testing.T) {
// removes that risk while it resolves, so the anchor is checked here rather
// than trusted.
func TestUnresolvedCompactionRunbookExists(t *testing.T) {
const guide = "../../../site/src/content/docs/guides/troubleshoot.mdx"
const guide = "../../../site/src/content/docs/docs/guides/troubleshooting.mdx"
data, err := os.ReadFile(guide)
if err != nil {
t.Fatalf("read the runbook the startup log links to: %v", err)
Expand Down
Loading
Loading