ADR-030: Metrics as a sub-package of pkg/observ, not part of it
Status: Accepted Date: 2026-04-21
Context
Phase 1 of the observability rollout shipped pkg/observ as the
foundation for every gyrum Go service: build identity, JSON slog logger,
probe primitive, layered redactor, chainable error wrapper. Phase 2a
layered HTTP access middleware on top of it with no new external
dependencies — just stdlib and the existing observ internals. The
package is intentionally cheap to import.
Phase 2b introduces prometheus metrics. The prometheus/client_golang
library pulls roughly ten transitive dependencies (client_model,
common, procfs, google.golang.org/protobuf, cespare/xxhash/v2,
beorn7/perks, munnerz/goautoneg, etc.) plus their own dependencies.
Measured on this branch: go.sum grew by 44 lines / 10 modules on a
module that previously had 16 lines of non-stdlib deps.
Not every service needs metrics. Background workers, CLIs, migration
tools, test harnesses, and auxiliary daemons want the logger, probe,
redactor and error wrapper but have no HTTP surface to scrape — handing
them prometheus as a transitive cost is waste. More importantly, it
creates an asymmetry: the cost of a single base-library import
(pkg/observ) is meaningfully larger for everyone because a subset of
consumers need the one feature that drags the dep tree in.
The decision is where metrics live in the gyrum-go source tree.
Decision
Metrics ship as a sub-package at pkg/observ/metrics/. Consumers
opt in with a second import line:
import (
"github.com/gyrum-labs/gyrum-go/pkg/observ"
"github.com/gyrum-labs/gyrum-go/pkg/observ/metrics"
)
Services that want only logging stay on the base import and pay no prometheus cost. Services that want metrics add the second import; the dep tree grows for them alone.
The sub-package depends on the base package (for GetBuildInfo,
Route, FromContext), but the base package does NOT depend on the
sub-package. go test ./pkg/observ/... exercises both; go build ./pkg/observ exercises only the base and confirms the one-way
dependency holds.
Naming: pkg/observ/metrics, not pkg/metrics, because the sub-package
is a second view on the same observability concern — build identity,
route label, cardinality discipline all come from observ — and colocating
the two makes the relationship obvious from the import path.
Consequences
- Lean base import. A service that imports only
pkg/observcontinues to pay what it paid before Phase 2b: one library, a handful of stdlib deps, zero prometheus chain. Measured: base go.sum delta is zero on services that do not add the metrics import. - Explicit opt-in for metrics. Teams wiring metrics into a new service make a deliberate choice; the second import is a visible trigger for "did I think about scraping, dashboards, cardinality".
- Two middleware layers to wire, not one. The composition
observ.Access()(metrics.Instrument(mux))is slightly more verbose than a single bundled middleware would be. Documented in the sub-package README with the specific reason: Access emits logs, Instrument emits metrics, both read the same route context, they compose but do not collapse. - Shared types (BuildInfo, Route) live in the base package. The sub-package must read them through the base import — which is intentional: the base is the canonical source for identity, and the metrics sub-package is a consumer, not a peer.
- Two READMEs. The base
pkg/observ/README.mdadvertises the sub-package and links to it;pkg/observ/metrics/README.mdis the dense reference for operators wiring metrics. Duplication is bounded to the composition example and the cardinality rule — both worth repeating where readers land. - CI gate:
go build ./pkg/observmust succeed without the metrics sub-package. Catches accidental upward imports that would re-merge the dep trees and defeat the point of the split.
Alternatives considered
- One package, prometheus as a direct dep. Simpler wiring (one import), but every downstream service pays the ~10-dep cost whether they scrape or not. Rejected because the Phase 1 package is deliberately cheap — the dep curve matters more than the import ergonomics.
- Separate top-level package
pkg/metrics. Equivalent dep isolation but loses the semantic colocation. Metrics and logs share build identity, route labels, and the cardinality rule — splitting them to sibling directories makes cross-reference harder and invites divergence. Rejected on cohesion grounds. - Build tag (
//go:build metrics) in the same package. Would keep a single import path but make the metrics API available only when a build tag is set. Rejected: build tags are a power tool for platform code, not for feature toggles; tooling (gopls, staticcheck) handles sub-packages better than conditional compilation; and the pattern is surprising to the reader. - Separate module under
gyrum-go-metrics. Would isolate the dep chain via go.mod rather than package structure. Rejected: forcing a second module creates a versioning treadmill (two version files, two release cuts, two changelogs) for one feature, and the dep-isolation win is the same as a sub-package.
Supersedes: none Superseded by: