The dogfooding fleet
A local rolter with a fleet that looks like a real one, for sitting down as an operator rather than as the person who wrote the screen (#924).
The built-in fake-llm model answers “does the gateway work at all”. It does
not answer “what is it like to run this”: one route with one target exercises
none of the surfaces an operator lives in — provider groups, routing
strategies, per-target health, key scoping, analytics — and every balancing
strategy looks identical when every target answers in the same time.
The harness lives in integration/dogfood/. This page is the entry point; the
directory’s README.md is the detail, including SigNoz and the .localhost
names.
What it stands up
| Piece | Where |
|---|---|
fifteen fake OpenAI-compatible upstreams on 127.0.0.1:18001-18015 | integration/dogfood/fleet.ts |
| the gateway’s runtime config (listen address, request-log sink) | integration/dogfood/gateway.toml |
| the fleet as desired state — fifteen providers, three provider groups, eleven routes | integration/dogfood/dogfood.toml |
| the keys those upstreams expect (fake, loopback-only, checked in on purpose) | integration/dogfood/keys.env |
| the one local login every service shares | integration/dogfood/creds.env |
| the SigNoz dashboards the session is read through | integration/dogfood/signoz/dashboards/ |
Three shapes of upstream, named the way each of them names things:
:18001— OpenAI’s model names (gpt-4o,o3-mini), added as anopenaiprovider:18002— OpenRouter’svendor/modelnames, declaredkind = "openrouter":18003-18015— a self-hosted vLLM/TEI fleet, one model per instance, roughly half behind a key
Three of them exist so failure is legible: vllm-a100-03 is ~4x slower than its
pair, vllm-spot-01 returns a 503 for about a quarter of requests, and
vllm-spot-02 takes ~1.4s to first token. A fleet with no bad targets leaves
the health, breaker and latency screens permanently green, and a permanently
green screen tells an operator nothing.
Running it
just dogfood # datastores, fleet, control, gateway, SigNoz, then the sheet
just dogfood-sheet # re-print every url, login, endpoint and model
just dogfood-key # mint another gateway virtual key
just dogfood-seed # re-import dogfood.toml over a running stack
just dogfood-adaptive off # serve deepseek-r1 from its fallback stack (`on` undoes it)
just dogfood-ux # prove the dashboard UX capture works, before relying on it
--import is desired state, so re-importing an edited dogfood.toml updates
the rows it already created rather than duplicating them. The gateway never
reads that file: it runs on gateway.toml, and gets the fleet from the control
plane’s snapshot. The integration README’s
which config file does what
says which setting belongs in which file (#1288).
The whole stack binds to loopback, talks only to the fake providers, and holds
nothing real. That is the only reason its credentials are checked in — do not
carry them anywhere else, and leave docker/docker-compose.yml and the Helm
chart on their own defaults.
Things that are load-bearing and easy to miss
-
The stack enforces RBAC, and that is the point.
just dogfoodgeneratesROLTER_ADMIN_TOKEN,ROLTER_INTERNAL_TOKENand both peppers intointegration/dogfood/.tokens.envon first run and serves/internal/*on its own port,4002, as the e2e stack does (#636). RBAC only enforces once the control plane has an admin token, so a stack started without one runs with the operator API wide open — and then cannot surface the one class of bug dogfooding exists to catch: #942, where/me/*routes401ed while every admin route passed, is invisible on a stack that never authenticates anything (#1649). A hand-rolled run must set all four. Curl the operator API with-H "authorization: Bearer $ROLTER_ADMIN_TOKEN";just dogfood-sheetprints every value. -
The two peppers must outlive a restart, like the KEK.
ROLTER_KEY_PEPPERis mixed into a stored virtual-key digest andROLTER_SESSION_PEPPERinto a session, so regenerating either invalidates every key and logs everyone out. That is why they are written once to a gitignored file rather than being checked in beside the other local credentials, and why the gateway is given the sameROLTER_KEY_PEPPERas the control plane — the snapshot carries no pepper, so a mismatch rejects every key with401 invalid api key. -
The gateway names itself, and says so.
ROLTER_NODE_IDstill wins, but a gateway that has none now falls back toHOSTNAMEand then to thegethostnamesyscall — which a shell-launched process does have, even though it has noHOSTNAMEin its environment. It logs which one it used at boot (node identity resolved), and warns when nothing answered. SetROLTER_NODE_IDanyway when running more than one gateway on one host, since otherwise both heartbeat as the same node.just dogfoodsets it.This used to be silent: no id meant no node header, the control plane dropped both the cluster heartbeat and the adaptive-routing report with a
204, nothing was logged on either side, and the Cluster and Adaptive Routing screens stayed empty forever. The telemetry ingest now answers400to a report it cannot key on, which the gateway’s reporter logs asadaptive-routing telemetry report failed. See #1644. -
Everything in
dogfood.tomlis in the explicitreadonlytier. Providers and groups alike are written[[providers.readonly]]/[[provider_groups.readonly]]rather than as the deprecated bare arrays (ADR-0022), because this file is what a new operator reads before writing their own (#1657, #1650). Thedefaulttier is not an option for this stack: giving the control plane a config file switches it toMergedConfigStore, which drops db-created virtual keys from the snapshot (#623) and then 401s every key the stack mints. An unknown tier name is not an error — the config lint reports it as an unrecognised key and the entries are silently ignored, so a typo costs the whole fleet. -
Provider groups only propagate through a seed or a restart, and do not fan out. A group created through the dashboard does not bump
config_version, so the gateway never learns about it andgroup-slug/model404s until it restarts (#1643).dogfood.tomlseeds three groups for that reason, so group addressing is exercisable today — but every request to a group currently lands on its first member whatever the strategy and weights say (#1655). Groups in this file are in the explicitreadonlytier: thedefaulttier is seeded by the control plane from its ownROLTER_CONFIG, and giving the control plane a config file switches it toMergedConfigStore, which drops db-created virtual keys from the snapshot (#623) and 401s every key this stack mints. -
allow_custom_api_basedoes not survive the database.dogfood.tomlsets it on the OpenRouter-shaped edge, but the column does not exist yet (#1133), so once the fleet is seeded the control plane omits that provider from the snapshot and theclaude-sonnet-4route with it. The gateway says so on every reload and the dashboard shows it under config problems; it is expected until #1133 lands. -
Adaptive routing is switched on by
just dogfood.deepseek-r1is the fleet’sstrategy = "adaptive"route, and that strategy only routes once the deployment-wide kill switch is on. The switch ships off, and the importer does not write it (#1818), sojust dogfoodflips it on throughPUT /api/v1/adaptive-routing-policyonce the control plane answers (#1817). The route engages aftermin_samplespicks, 50 by default. To compare against the fallback stack, runjust dogfood-adaptive offor use Adaptive Routing → Settings. Either change survives a control-plane restart, and the nextjust dogfoodturns the switch back on. Onlyenabledchanges: blend weights set on the Settings screen stay as they are. -
ClickHouse wants 262144 open files, and some runtimes cannot grant it. Rootless Docker or Podman and sandboxed runners often cap
nofilelower. There both ClickHouse containers fail witherror setting rlimit type 7: operation not permittedand everything downstream (request logs,ui_events, SigNoz) is missing. PutCLICKHOUSE_NOFILE=20000indocker/.envthere, not in a repository-root.env, which compose does not read for these files (#1819). A one-offCLICKHOUSE_NOFILE=20000 just dogfoodworks for that run, but the next compose call without it,just signoz-resetincluded, recreates ClickHouse at 262144. -
Analytics need the gateway’s
[logging].clickhouse_url, not just the control plane’sCLICKHOUSE_URL. The control plane’s only lets it read the table (#929). -
The SigNoz dashboards are tied to the SigNoz pin.
provision-signoz.shtargets v0.136.0, the releasedocker/docker-compose.signoz.ymlpins, which moved sign-in to/api/v2/sessions/email_passwordand dashboard creation to/api/v2/dashboards(#1864). The boards are written in the v1 import format with"version": "v5", which v0.136 converts on import. A SigNoz the script cannot talk to makes it exit 2 naming the version, a sign-in route that moved included: only a refused credential is reported as a different account and pointed atjust signoz-reset.just dogfoodrepeats the failure after the sheet, andjust dev-credsexits with it. Runjust signoz-provisionagainst a new release before moving the pin.
Before a week of capture: prove the UX stream
A dogfooding week is worth what it captures. The dashboard’s own UX stream —
screen views, time-to-interactive, form abandons, error states — is the half
that cannot be reconstructed afterwards from traces, and it is also the half
with no failure signal: ui/src/lib/ux.ts swallows every error on purpose, so a
stack that is storing nothing looks exactly like a stack nobody clicked. A week
run on a broken pipeline is a week that has to be run again.
So start it by proving the pipeline instead of assuming it:
just dogfood-ux
That signs in, posts a probe event through the real
POST /api/v1/ui-events, reads it back out of ClickHouse, and fails loudly with
the reason if any hop is broken. It first applies clickhouse/*.sql to the
running server, which is not a formality: docker-compose mounts that directory
into ClickHouse’s docker-entrypoint-initdb.d, and those scripts run only
when the data directory is first created. A chdata volume older than a given
migration has never seen it, so a machine that has been running this stack for a
while may have no ui_events table at all — and then every batch is a 500 and
the week captures nothing. just dogfood now applies them on every boot for the
same reason.
What a mid-week ClickHouse hiccup costs
Every row here was produced by breaking the running stack and watching it, and the full table with the server’s status codes is in UX telemetry.
- ClickHouse restarts under you: the events queued during the outage are
gone, and the stream resumes by itself afterwards. Bounded loss, no action
needed — the browser gets a
500, drops that batch, and keeps flushing. - The
ui_eventstable is missing: the same500, but it never clears, so the whole week is lost. This is the onejust dogfood-uxexists to catch. - Your dashboard session lapses while a tab is open: the stream disables itself for the life of that tab. The screens keep working, so nothing hints at it — reload the tab after re-authenticating, and prefer to close tabs you are no longer using rather than leave one open overnight.
- A batch is rejected: everything that shared that flush is dropped, not just the offending event. A flush is at most five seconds of interaction.
In every case except the last the loss is silent from both ends: the dashboard
shows nothing and the control plane logs nothing. Re-running just dogfood-ux
at the end of a session is the cheapest way to know the capture was still alive
when the session ended.
Using it
The point is not that it boots. The point is to do operator work against it — add a provider, group it, route to it, scope a key, push traffic, then read the dashboard and the traces back — and to file an issue for every place that goes badly. A finding mentioned in a summary is lost; a finding on the board is not. The board conventions are in issue tracking.
If an operator action produces no useful span in SigNoz, that is an observability gap and worth its own issue too.
Walking it as someone else
“Do operator work” has a script now: the user journeys walk each kind of person — org admin, team lead, engineer, service, FinOps, SecOps, DevOps, viewer — through their goal, step by step, with the status of every step and the issue behind every gap. They sign in as persona accounts, which one command creates on this stack:
just dogfood-personas # or ./integration/dogfood/personas.sh
It is idempotent and prints the roster: one account per persona, each holding
exactly the role and scope its script assumes, all sharing the dogfood password
from creds.env.
The scripts also run unattended. integration/dogfood/journeys/ holds one
TypeScript file per persona that walks its steps against this stack. Dashboard
steps run in headless Chromium, signed in through the real login form. The rest
call the API as the persona or with the keys the script mints, and each script
cleans up what it made:
just dogfood-journeys # every script; `just dogfood-journeys lead app` for some
just dogfood-screens # every persona account against every dashboard screen
Results, one screenshot per step and a summary table land in
integration/dogfood/.journeys/. The runner uses the dashboard’s own
playwright-core (cd ui && bun install first), and CHROMIUM_PATH points it
at a system Chromium when Playwright’s is not installed. The CLI steps (kek verify, mfa reset, config export) need cargo build -p rolter --features postgres and are skipped without it.