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, because that is its entire job,
and nothing else. Apart from the OpenTelemetry modules, the only module the library packages import
is go/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; the comments in go.mod say why.
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. gitlab.com/phpboyscout/go/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.go's test-graph tier refuses only go-tool-base, Viper, Cobra and the Discord SDK, so testcontainers passes it, yet 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). That 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.ymlincludes the components at@main, 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 used to count it and read low (98.2% real against 73.0% badged when this was
first written). That gap was in the shared go-test component, tracked as Part A of
phpboyscout/cicd#5, which has since closed: the
component's exclude_generated input now defaults to true and strips files carrying the
// Code generated ... DO NOT EDIT. marker from the badge's profile. Until a pipeline here has been
seen to agree with just coverage-total, quote the local figure, and keep the local exclusion.
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.
colophon 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 colophon: merging to main opens or updates a Release
merge request carrying the pending version bump and changelog, and merging that lets the next
main pipeline tag the commit it landed. Never tag manually. colophon rewrites the Release
MR's description on every run and never reads it back, so release notes and overrides live in the
repository: a Release-Note: trailer for prose, a Release-As: X.Y.Z trailer to force a version,
and .colophon.yaml to hold a release.
There is no goreleaser and no release-asset signing, because 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 a
Release-Note: trailer, 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.