ADR-051: Observability-by-default in the build-full pipeline
Status: Accepted Date: 2026-04-22
Context
build-full (the 73-step pipeline at
ai-research-dev/cmd/server/pipeline/) generates brand-new SaaS products
from briefs. Every product shipped through this pipeline today comes out
of the factory obs-UNWIRED: no pkg/observ import, no SetBuildInfo,
no /metrics or /probe routes, no version/commit ldflags in the
Dockerfile, no Grafana folder or dashboards. Someone — usually an
operator — then runs gyrum-observ-integrate
(ADR-045) to retrofit
the 6–8 stage wiring by hand before the product is useful in production.
The retrofit works but it is the wrong default. The factory's whole point is to ship things ready to run; "we generate everything except the one thing that tells you whether it's running" is an obvious hole, and it compounds: the longer a generated product lives without observ, the more its main.go drifts away from the reference shape, and the more surgical the retrofit becomes.
The hard part is the splice. Backend code inside build-full is
authored by an LLM (see buildBackend in
ai-research-dev/cmd/server/pipeline/build_phase.go, step 15 of
BuildPhaseSteps()). The shape of the generated cmd/<slug>/main.go
varies run-to-run — the router may be stdlib net/http, chi, or a
hand-rolled ServeMux; the middleware chain may be composed inline or
hidden behind a helper; imports may be grouped by tool or by hand. Any
regex- or AST-based edit that tries to reach into that output and splice
in observ.Access() + metrics.Instrument is exactly as fragile as the
variance in the generator. An earlier attempt to implement
"observ-by-default" this way was correctly halted for that reason.
Live state this ADR must preserve as-is:
- ADR-050 — every
product's
/metricsroute is wrapped inobserv.MetricsAuth(token), with the token sourced fromOBSERV_METRICS_TOKEN_FILE. Thedocker-volumes.txtmount for the token file and the env-var reference in.env.exampleare part of the wiring contract. - ADR-048 — Caddy sites on ops VPS are codified, not hand-edited.
- ADR-047
— each product gets its own Grafana folder (uid = slug) and pins the
prometheus/lokidatasource uids. - ADR-046 — the Tier-1 dashboard pack is the canonical per-product set.
gyrum-dashboard-scaffoldshells out to templates atdark-factory/infrastructure/grafana-templates/; its contract is the stable seam, not the templates themselves.
Decision
Make the build-full pipeline produce fully obs-wired products on
first emit, via a two-part sequenced change: the generator writes
most of the wiring itself, and a new verifier/gap-filler pipeline step
confirms it and plugs deterministic holes. Arbitrary regex or AST
editing of LLM-generated main.go is explicitly rejected as a
mechanism — we do not parse the output shape, we steer the generator
toward a shape we can splice into at a known marker, and we WARN (never
fail) when the marker is missing.
Part 1 — prerequisite: modify the buildBackend prompt
The prompt in build_phase.go's buildBackend CustomFn is updated so
that the generated backend, from run 1, already includes:
import "github.com/gyrum-labs/gyrum-go/pkg/observ"and the metrics sub-package (ADR-030).observ.SetBuildInfo(version, commit)at startup (ADR-032), sourced from-ldflagsvars./metricsroute wrapped inobserv.MetricsAuth(token)per ADR-050./proberoute per ADR-009.A stable marker comment immediately before the final
middleware.Chain(...)call in the server's middleware setup:// observ:wiring-point (do not remove — see ADR-051) handler := middleware.Chain( observ.Access(), metrics.Instrument(), // ...product middleware )
The generator becomes the primary author of observ-wired code. The pipeline step that follows is a verifier and gap-filler, not a surgical editor.
Part 2 — the step: "Wire observability"
A new step is added to BuildPhaseSteps() in build_phase.go,
immediately after "Build backend from blueprint" (step 15) and
before "Build frontend (TDD)" (step 16). The step is a CustomFn, not an
AI-prompt step — its logic is deterministic.
For each product directory, the step:
- Validates the generated
cmd/<slug>/main.go:- imports
pkg/observandpkg/observ/metrics? - calls
observ.SetBuildInfo(...)at startup? - registers
/metrics(wrapped viaMetricsAuth) and/proberoutes? - the
// observ:wiring-pointmarker is present withobserv.Access()andmetrics.Instrument()inside the chain?
- imports
- Gap-fills deterministically where the answer is cheap and
unambiguous:
- If the marker is present but
observ.Access()ormetrics.Instrument()is missing from the chain, splice them in at the marker. The splice is text-level — but anchored to the marker, not to parsed AST — so it is local and idempotent. - If the marker is absent, do not try to guess where to splice.
WARN into
OBSERV-WIRING.mdand move on.
- If the marker is present but
- Patches the Dockerfile deterministically: Dockerfile generation
already follows a fixed template, so merging
ARG VERSION/ARG COMMIT+-ldflagsintopkg/observ.buildVersion/buildCommitis safe to do directly (see the reference Dockerfile atdistill-gyrum-ai/Dockerfile). If an existing-ldflagsclause is present, merge into it — Go honours only the final-ldflags, so doubling the flag silently drops earlier values. - Scaffolds Tier-1 dashboards by shelling out to
gyrum-dashboard-scaffoldwith the product slug (ADR-046, ADR-047). Reimplementing template substitution inline would drift the two paths. - Writes
docker-volumes.txtwith the MetricsAuth bearer-token mount line per ADR-050, and addsOBSERV_METRICS_TOKEN_FILEto the generated.env.example. The pipeline does not generate the token value — that is an operator secret, provisioned duringdeploy.sh(see "Open question: token provisioning" below). - Writes
OBSERV-WIRING.mdat the product root: a short, machine-readable status summary listing which checks passed, which gaps were filled automatically, and which gaps requiregyrum-observ-integrateto complete. This file is the operator's landing page for "what did and did not happen."
Explicitly out of scope
Frontend observability is deferred to a future ADR (tracked as
ADR-05X). The SvelteKit side needs a different story: structured
logging from the server hooks, client-side error reporting, no
/metrics endpoint, and potentially a different scrape topology. The
gap is known, acknowledged, and deliberately left for that ADR.
Consequences
Good:
- Observ-by-default on new products. Every product emitted by
build-fullafter this change ships withpkg/observwired,/metricsbearer-auth'd (ADR-050), a provisioned Grafana folder (ADR-047), and a Tier-1 dashboard pack (ADR-046). - Bearer-auth ships on day one. There is no "we'll add auth later" debt window. Distill's issue #269 does not repeat for new products.
- The retrofit CLI keeps its role.
gyrum-observ-integratedoes not compete with this change — it becomes the repair tool for pre-existing products and for drifted integrations. New products generated bybuild-fulldo not need to run it; operators invoke it only whenOBSERV-WIRING.mdsays a gap was not filled, or when an older product is being brought up to the current contract.
Trade-offs:
- The
buildBackendprompt becomes load-bearing on the marker. Test coverage must exercise that the marker is emitted across model versions. This is real maintenance surface — when Claude's model version is bumped, the prompt must be re-validated. - WARN-over-fail. If an AI run forgets the marker, the step
records it in
OBSERV-WIRING.mdand the pipeline proceeds. The product deploys but is only partially wired until someone runsgyrum-observ-integrate. This is deliberate — a full pipeline fail on a prompt-drift issue would be worse than a partial wiring with a loud, structured note. - Implementation size. The step is ~200–400 lines (validator + deterministic splicers + Dockerfile patcher + scaffold shell-out + volume/env writer + summary writer). Not tiny, but each sub-unit is testable in isolation against a fixture main.go.
- Wall-clock cost. +5–15s on a ~73-step run. Negligible.
Risks:
- Prompt drift as model versions evolve. Mitigation: golden-fixture tests that pin the expected marker shape and post-generation validator pass.
- Marker silently removed by a future prompt edit. Mitigation: a codified contract comment on the prompt block pointing at this ADR, plus a validator test that fails if the prompt string no longer contains the marker instruction.
Alternatives considered
(a) Pure regex/AST editing of generated main.go. What was initially attempted. Rejected: regex brittleness on LLM output is the entire problem; AST rewriting moves the fragility from "which characters match" to "which syntactic shape matches," and the shape varies just as much. Net win is small; surface is large.
(b) Post-pipeline retrofit step at first deploy. Run
gyrum-observ-integrateas the last action ofdeploy.sh. Rejected: violates "observ by default"; shifts the burden from the build-time context (code, prompts, fixtures all together) to deploy-time (where context is gone). Also brittle — a product deployed by a third party may never reach this step.(c) Port
gyrum-observ-integrateinto Go as a pipeline-runnable library. Tempting — the CLI already does all of this. Rejected for this ADR: the CLI's shape is <1 month old; extracting a library now locks in premature abstractions. Revisit once the CLI has stabilised across 2–3 more products.(d) Marker-splice + generator-prompt change — chosen. See above.
(e) Extend the existing "Monitoring setup" step (step 63 in
InfraPhaseSteps()) from AI-generated markdown to real wiring. Rejected: that step writesinfrastructure/monitoring.md(docs), not code. The wiring targets (cmd/<slug>/main.go,Dockerfile) are authored in the Build phase — by step 63 they are frozen. Also: "Monitoring setup" and "observ wiring" have different contracts.(f) Emit observ via a template engine, replacing the LLM for this surface. Rejected: the pipeline's contract is "AI writes production code." Carving out a template island makes that surface non-idiomatic; the marker approach delivers the same outcome while keeping the AI as author.
Explicit decisions the reader should be able to re-examine later
Where the marker comment goes. In the middleware setup, immediately before the final
middleware.Chain(...)call in server-setup (commonly inmain.goor aregisterRoutes-style helper). The concrete snippet is under "Part 1" above. If a future generator structure moves chain composition elsewhere, this is the first thing to revisit.Fallback when the marker is missing. WARN into
OBSERV-WIRING.md, log a structured INFO from the pipeline step (msg="partial wiring — run gyrum-observ-integrate to finish"), pipeline proceeds. The product deploys with/metrics+/proberoutes (if the generator got those right) but possibly withoutobserv.Access()+metrics.Instrument()in the chain.The MetricsAuth token is not generated by the pipeline. The token is an operator secret per ADR-050. The pipeline generates the
docker-volumes.txtmount line and the.env.exampleenv-var reference only. First deploy provisions the token from thedeploy.shflow; the observability runbook is the canonical procedure.Interaction with
gyrum-observ-integrate. The CLI becomes the repair tool: run it on products that predate this ADR, and on any new product whoseOBSERV-WIRING.mdreports a gap. There is no collision — both paths converge on the same contract (ADR-045's integration spec), and the CLI's--checkmode is the audit loop for either origin.
Decisions (formerly open questions — reviewed 2026-04-22)
- Marker format: human-readable
// observ:wiring-point (do not remove — see ADR-051). Directive form was considered and rejected: the marker is load-bearing documentation as much as machine signal, and future human readers of LLM-generated code need the hint to not silently delete it during cleanup. - Where the new step lives: last step of
BuildPhaseSteps(), after "Page content verification". A wiring-induced build break is then caught against a smoke-tested baseline, not before the baseline exists. Also localises "observ wiring failure" away from the backend-build step's own failure mode, which keeps root-causing simpler when something goes wrong. - Scope of
OBSERV-WIRING.md: per-product status file at the generated product's root. Does NOT write intodark-factory/docs/observability-fleet-status.md— that matrix is regenerated by the ADR-045 audit script and any hand-written content is blown away on the next run. Per-product is where developers of that product first look; cross-product audit is already covered byscripts/observ-integration-audit.sh.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one
Related
- ADR-008 — single library for observ primitives.
- ADR-030 — metrics sub-package.
- ADR-032 — build-info identity gauge.
- ADR-045 —
gyrum-observ-integrateas the retrofit CLI; becomes the repair tool under this ADR. - ADR-046 — Tier-1 pack.
- ADR-047 — folder-per-product Grafana convention.
- ADR-050 —
bearer-auth on
/metrics; hard dependency of this design. ai-research-dev/cmd/server/pipeline/build_phase.go—BuildPhaseSteps()andbuildBackend.docs/observability-runbook.md— operator procedure for token provisioning on first deploy.