Skip to content

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/testify for assertions.
  • github.com/cockroachdb/errors for all error creation and wrapping.
  • Exporter-construction failure paths belong in exporter_error_test.go alongside the package.
  • New code needs at least 90% coverage.
  • No package-level mocking hooks. A var newExporter = otlptracehttp.New seam races under t.Parallel(). Inject through functional options or struct fields instead.
  • Prefer adding a functional option to an existing NewProvider over 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.go runs go list -deps without -test, so test-only dependencies never trip it — yet testcontainers still drags the Docker SDK into go.mod and go.sum. A separate module under test/ with its own go.mod is 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/cicd go-test component ships a dind-backed go-test-integration job built for testcontainers-go, but only from v0.26.0. This is no longer a blocker: .gitlab-ci.yml pins 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 a v0.22.0 pin; 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 (errcheckgocriticstaticcheckexhaustivenestifcyclop) 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 a rel=canonical pointing 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 Commitsreleaser-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.