Contributing¶
observability is a small, framework-free library in the
phpboyscout Go toolkit, extracted from
go-tool-base. It is consumed by other modules and by
real services, so every exported symbol is a contract. The working method below exists to keep that
contract trustworthy.
If you are an AI agent, AGENTS.md in the repository root is the canonical version of this method,
and .agent/ holds the executable workflows and skills.
The three invariants¶
Almost every design question about this module resolves to one of these. Breaking any of them is a specification-level decision, never an implementation detail.
1. Framework-free. The module carries the OpenTelemetry SDK — that is its entire job — and
nothing else. The only non-OTel runtime dependency is cockroachdb/errors. depfootprint_test.go
enforces this in the normal test suite: it allows go.opentelemetry.io and fails on go-tool-base,
Viper/Cobra/pflag, Charm, and the AWS/GCP/Azure SDKs. A regression that reintroduces framework or
vendor coupling fails a test rather than silently bloating every downstream binary.
2. Typed values in, providers out. otelcore works from a typed Settings; the caller owns how
those values are sourced — flags, environment, a config file, whatever they already use. The module
does no config parsing and no environment scanning of its own. Removing that coupling is precisely
why the module was extracted, so pulling a config framework back in undoes the work.
3. The signal packages stay independent. logs, metrics, and tracing each build their own
exporter. A service that only traces must never link the log or metric exporters. Shared,
transport-neutral logic belongs in otelcore — never duplicated across the three.
One corollary worth stating on its own: an empty Endpoint is intentional, not an error. It
means "fall back to the standard OTEL_EXPORTER_OTLP_* environment variables", which the SDK
handles. Do not turn it into a validation failure, in code or in documentation.
OTel version lockstep¶
The OpenTelemetry dependency versions are pinned to match go-tool-base's, so a service built on GTB
and this module resolve to a single OTel build graph. gRPC and the split genproto modules are
pinned for the same reason — see the comments in go.mod.
Grouped Renovate updates keep them aligned. Never bump an OTel module on its own. If a bump is needed out of band, it has to move go-tool-base too, which makes it a coordinated change across repositories.
Lifecycle¶
1. Specification first¶
Non-trivial changes — a new package, any public API addition or change, a new dependency, a change
to the OTel version floor — start with a specification in
the project wiki, reviewed and marked APPROVED before implementation
begins. Bug fixes, doc corrections, and refactors that leave the public API untouched proceed
directly.
Spec status moves to IN PROGRESS when work starts and IMPLEMENTED when it lands. The spec and
its implementation share a branch and a merge request, so the design rationale sits next to the code
in git history.
2. Test-driven implementation¶
Write the failing tests first, derived from the spec's public API contracts, error cases, and edge cases. Implement the minimum code to pass them, then refactor with the suite green.
- Table-driven tests with
t.Parallel();stretchr/testifyfor assertions. github.com/cockroachdb/errorsfor all error creation and wrapping.- Exporter-construction failure paths belong in
exporter_error_test.goalongside the package. - New code needs at least 90% coverage.
- No package-level mocking hooks. A
var newExporter = otlptracehttp.Newseam races undert.Parallel(). Inject through functional options or struct fields instead. - Prefer adding a functional option to an existing
NewProviderover changing its signature, so adoption is not a breaking change.
Tests that talk to a real OTLP collector are integration tests. Gate them with an environment
variable rather than a build tag, so they stay compiled and discoverable in an IDE, and put them in
a dedicated *_integration_test.go.
Mocks¶
Mocks are generated by mockery from .mockery.yml into
mocks/, as a public package, mirroring go-tool-base. mocks/otelcore/SettingsSource.go is the
only one so far. Regenerate with just mocks, and never hand-edit a generated file — change the
interface and regenerate. The scan is recursive, so a new interface is picked up automatically.
Because mocks/ is a normal package rather than test-only, stretchr/testify forms part of the
module's importable graph. That is deliberate: the point of publishing the package is that a
consumer wiring SettingsSource into their own service can mock it in their tests without
hand-rolling a double.
Testing tooling that is planned but not yet here¶
Testcontainers (a real collector to export against) and godog (E2E scenarios) are both intended.
Neither is wired up: there is no test/e2e/, and the go-test CI component runs with
enable_e2e: false.
Each carries a decision that outlives the test file, so both arrive through a specification rather than ad hoc:
- How heavy test dependencies stay out of consumers' module graphs.
depfootprint_test.gorunsgo list -depswithout-test, so test-only dependencies never trip it — yet testcontainers still drags the Docker SDK intogo.modandgo.sum. A separate module undertest/with its owngo.modis the usual answer. - Whether Gherkin earns its keep. A provider constructor is not a user workflow, and a table test describes it better. The case that does hold up is genuinely end-to-end — given a collector, when a service exports, then the spans arrive intact — which may read well as a scenario, or may just be a testcontainers integration test with extra ceremony.
- Which CI component version. The
phpboyscout/cicdgo-testcomponent ships a dind-backedgo-test-integrationjob built for testcontainers-go, but only from v0.26.0. This is no longer a blocker:.gitlab-ci.ymlpins the components at v0.34.1, so the job is available and adopting testcontainers is a matter of following its conventions (./test/integration/...,INT_TEST_INTEGRATION). Earlier notes here and in the RED/USE spec describe av0.22.0pin; that is stale.
3. Verification¶
just ci # tidy, test, test-race, lint
just coverage # HTML coverage report
just coverage-total # the coverage % to quote
just vuln # govulncheck
Both coverage targets exclude generated packages. That matters more than it sounds: mocks/ has no
tests, so counting it puts a 0% package in the denominator — worth about 25 points on a module this
small. The CI badge does not yet exclude them and therefore reads low (98.2% real against 73.0%
badged, at the time of writing). Quote just coverage-total, not the badge. This is a gap in the
shared go-test component rather than anything local, tracked as Part A of
phpboyscout/cicd#5 — it will strip files carrying
the // Code generated ... DO NOT EDIT. marker from the profile centrally, at which point the badge
becomes trustworthy and the local exclusion here can be dropped.
Re-run the tests after any lint work, not just before it: nestif and cyclop refactors can
silently alter behaviour. Resolve lint issues simplest-first (errcheck → gocritic →
staticcheck → exhaustive → nestif → cyclop) and fix the root cause rather than reaching for
//nolint.
4. Documentation¶
Any functional change updates the documentation in the same change, cross-referenced against the code so that every signature and example matches the implementation exactly.
Structure follows Diátaxis: docs/tutorials/, docs/how-to/,
docs/reference/ and docs/explanation/ are the four quadrants, and contributor material lives
outside them in docs/development/.
Two rules that used to be deviations have been reversed, and pages written under the old ones are being brought across:
- A reference tier lives in the docs. Deferring reference entirely to
pkg.go.dev left the questions people
actually ask — what is this key, what does it default to, what happens if I set it wrong —
unanswered, because godoc documents signatures rather than defaults that live in the SDK, config
key names, or the environment contract. Write both: doc comments and
docs/reference/. Keep the pages honest by reading the symbol before writing about it, and name the OTel version any SDK-owned default came from. - Tutorials live in the docs, and that copy is canonical. They are no longer blog-post-only with
a link stub here. Where a phpboyscout.uk post covers this module, backport it into
docs/tutorials/and give the post arel=canonicalpointing at the docs page. Check the post is genuinely instructional first — a narrative or opinion piece is a different register and does not become a tutorial by being moved.
Moving a published page needs a redirect. These pages have inbound links from other projects and
from blog posts. Zensical has no redirects plugin — it implements only search, offline, mike
and mkdocstrings — so redirects go in docs/_redirects, which is copied verbatim to the site root
and read by GitLab Pages. Verify it lands in site/_redirects after a build rather than assuming.
zensical.toml sets no nav block, by design: the sidebar auto-discovers every page in
docs/, so all documentation — this contributor section included — is discoverable without curation
deciding what appears. A new page needs no nav edit to show up; do not add a nav block.
Commits and merge requests¶
Commits follow Conventional Commits —
releaser-pleaser reads them to compute the version bump and the
changelog. Use the package name as the scope (otelcore, tracing, metrics, logs) or deps,
ci, docs. One coherent change per commit; the body explains why.
Merge requests rebase and fast-forward onto main — no merge commits, and no squash-merge from the
GitLab UI. Spec-only merge requests are prefixed [SPEC].
Releases¶
Releases use the Release-MR pattern via releaser-pleaser: merging to main opens or updates a
Release merge request carrying the pending version bump and changelog, and merging that creates
the tag and the GitLab Release. Never tag manually.
There is no goreleaser and no release-asset signing — this is a library with no binaries. Module
integrity comes from go.sum and the Go checksum database, and publication is the tag itself: once
vX.Y.Z exists, the Go module proxy serves it.
The module is pre-1.0. The public API is not frozen — a breaking change ships as a minor bump
with no BREAKING CHANGE: footer, since a major bump is not wanted yet. Note the break in the
commit body, say what downstream call sites must change, annotate deprecations with
// Deprecated:, and check the impact on go-tool-base first: it is the primary consumer.