The boring, correct, loud way to turn OpenTelemetry on in a Go service — one constructor, vendor presets, and failures you can actually see.
otelkit stands up the OpenTelemetry trace/metric/log pipeline — providers, exporters, resource, propagators, and an ordered shutdown — from one explicit constructor. Point it at a backend (HyperDX, Grafana Cloud, Honeycomb, Datadog, New Relic, an OTLP collector, or stdout) and get correct, shutdown-safe telemetry without re-writing the usual ~150 lines of fiddly, failure-silent setup. The core has zero application dependencies; OTLP/gRPC ships as an opt-in contrib/ module so your dependency graph stays lean.
It is a bootstrap, not an SDK: it wires the official go.opentelemetry.io/otel SDK rather than reimplementing it. Writing log lines is a logger's job — otelkit exposes the LoggerProvider that github.com/ubgo/logger (and any OTEL log bridge) consumes.
OpenTelemetry's Go SDK ships excellent primitives and no opinionated bootstrap, and its dominant failure mode is silence: a wrong port (4317 vs 4318), wrong protocol, a missing /v1/<signal> path, an unflushed batch on exit, or a cumulative-vs-delta mismatch all fail without an error — producing empty dashboards and no clue why. otelkit fixes that:
- Spec-compliant — honors the standard
OTEL_*environment variables and defaults. - Vendor presets as data — switch backends in one line; the preset encodes the endpoint, auth header name/format, path quirk, and metric temporality.
- Loud, not silent — an export-error handler, a connectivity probe, a dry-run mode, and an opt-in boot self-test turn silent misconfiguration into a specific startup error.
- One knob, no footguns —
otelkitowns all port +/v1/<signal>path construction. - One handle, one ordered
Shutdown+ a ready-made signal helper; a real no-op onOTEL_SDK_DISABLED. - Future-proof — delegates to the now-stable declarative config (
otelconf) whenOTEL_CONFIG_FILEis set. - Zero application dependencies.
go get github.com/ubgo/otelkitOTLP/gRPC support is an opt-in module (keeps google.golang.org/grpc out of the core):
go get github.com/ubgo/otelkit/contrib/otelkit-grpcpackage main
import (
"context"
"log"
"github.com/ubgo/otelkit"
)
func main() {
ctx := context.Background()
tel, err := otelkit.Init(ctx,
otelkit.WithService("checkout", "1.4.2"),
otelkit.WithEnvironment("prod"),
otelkit.WithPreset(otelkit.PresetHyperDX("<ingestion-key>", "")),
)
if err != nil {
log.Fatal(err)
}
tel.SetGlobal()
defer tel.Shutdown(ctx)
// ... run your service; create spans/metrics via the OTEL globals ...
}For a long-running service, replace the defer with the signal helper:
go runServer()
if err := tel.RunOnSignal(ctx); err != nil { // blocks until SIGTERM/SIGINT, then flushes
log.Printf("shutdown errors: %v", err)
}otelkit is split so the core stays dependency-light. Add the gRPC module only if your backend needs OTLP/gRPC.
| Module | Import |
|---|---|
| core | github.com/ubgo/otelkit |
| gRPC exporters | github.com/ubgo/otelkit/contrib/otelkit-grpc |
The core ships OTLP/HTTP + stdout exporters and depends only on go.opentelemetry.io/otel/*. The gRPC module adds OTLP/gRPC (pulling in google.golang.org/grpc) and self-registers on a blank import. Selecting TransportGRPC without it returns otelkit.ErrGRPCNotLinked — loud, not silent.
Every observability backend wants OTLP data in a slightly different shape — a different endpoint, a different auth header name (there's no Bearer consensus), a path quirk, and sometimes delta-vs-cumulative metrics. Get one detail wrong and your data silently never arrives. A preset is a one-line constructor that fills all of that in correctly for a specific vendor — so pointing at HyperDX vs Grafana vs Datadog is a single line, not a research project.
| Preset | Auth header | Notes |
|---|---|---|
PresetStdout() |
— | Local dev; all signals to stdout. |
PresetHyperDX(key, endpoint) |
authorization (raw key, no Bearer) |
Defaults to https://in-otel.hyperdx.io. |
PresetGrafanaCloud(instanceID, token, endpoint) |
Authorization: Basic <b64> |
Endpoint is the /otlp base; /v1/<signal> is appended automatically. |
PresetHoneycomb(key, dataset, endpoint) |
x-honeycomb-team |
Metrics additionally send x-honeycomb-dataset. |
PresetDatadog(key, endpoint) |
dd-api-key |
Forces delta temporality (Datadog rejects cumulative). |
PresetNewRelic(key, endpoint) |
api-key |
Prefers delta temporality. |
PresetCollector(endpoint, transport) |
— | Generic OTLP, no auth. The vendor-neutral escape hatch. |
Switching backend is a one-line change:
otelkit.WithPreset(otelkit.PresetGrafanaCloud("123456", "<token>", "https://otlp-gateway-prod-eu-west-2.grafana.net/otlp"))Full matrix and the reasoning behind each quirk: docs/presets.md.
The OpenTelemetry SDK drops exports silently: a wrong key, endpoint, protocol, or TLS setting just loses data with no error, and you find out hours later from blank dashboards. otelkit gives you four ways to make those failures visible — at startup, not in production:
tel, err := otelkit.Init(ctx,
otelkit.WithPreset(otelkit.PresetHoneycomb(key, "metrics", "")),
otelkit.WithSelfTest(), // send one span synchronously; error if the backend is unreachable
otelkit.WithErrorHandler(myLogger), // route export failures into your logs (default: stderr)
)WithSelfTest()— sends one span through the real pipeline at startup and returns the export error the async batcher would otherwise hide.- Connectivity probe —
otelkit.ProbeEndpoint(ctx, endpoint, transport, tlsMode)diagnoses DNS / port / protocol / TLS problems with a human-readable message. WithDryRun()— prints the resolved effective config (auth headers redacted) and routes telemetry to stdout, so you can verify wiring with no backend.- Export-error handler — installed by default (stderr); override with
WithErrorHandler.
More: docs/diagnostics.md.
import (
"github.com/ubgo/otelkit"
_ "github.com/ubgo/otelkit/contrib/otelkit-grpc" // blank import enables gRPC
)
tel, _ := otelkit.Init(ctx,
otelkit.WithPreset(otelkit.PresetCollector("localhost:4317", otelkit.TransportGRPC)),
)Without the contrib import, selecting TransportGRPC returns otelkit.ErrGRPCNotLinked — loud, not silent.
otelkit accepts config from three independent routes (precedence: preset < options < env):
- Programmatic —
WithService,WithPreset,WithProtocol,WithSampler,WithTLS, … (map your own config system, e.g. PKL, into these). OTEL_*environment variables — the full standard surface (protocol, endpoint, headers, timeout, sampler, propagators, temporality). SetWithEnvOverrides(false)to make programmatic values authoritative.- Declarative config file — set
OTEL_CONFIG_FILEandotelkitdelegates to the stableotelconfloader (file wins; flat env is ignored except${ENV}substitution).
If you have the familiar ~150-line bootstrap (build three providers, attach OTLP exporters, set globals, wire shutdown): replace it with one otelkit.Init(...) call plus the matching preset. otelkit adds gRPC, vendor presets, loud diagnostics, the full OTEL_* surface, a real no-op on OTEL_SDK_DISABLED, declarative-config delegation, and an ordered Shutdown that flushes all three signals (instead of returning on the first error). Full before/after in docs/migration.md.
| Guide | Covers |
|---|---|
| Getting started | Zero to correlated telemetry in three lines. |
| Configuration | Options, the full OTEL_* env surface, precedence. |
| Presets | Vendor presets and what each encodes. |
| Diagnostics | Self-test, probe, dry-run, error handler. |
| Declarative config | OTEL_CONFIG_FILE delegation. |
| Migration | Replacing a hand-rolled bootstrap. |
| Architecture | How it fits; the endpoint/path rules. |
| ADRs · Snippets · Coverage | Decisions, copy-paste, test coverage. |
API reference: pkg.go.dev/github.com/ubgo/otelkit.
Runnable programs in examples/ — each in its own directory (go run ./01-basic):
01-basic · 02-all-signals · 03-k8s-prestop · 04-presets · 05-self-test · 06-dry-run · 07-declarative · 08-grpc
Both modules are held at 100% line coverage with the race detector, gated in CI. See COVERAGE.md.
Does otelkit replace the OpenTelemetry SDK? No — it's a bootstrap that wires the official SDK. You still create spans/metrics the normal OTEL way.
Does it write my logs? No. It builds the LoggerProvider; a logger (e.g. github.com/ubgo/logger) writes log lines through it and correlates them with traces.
Why is gRPC a separate module? To keep google.golang.org/grpc out of the core dependency graph for HTTP-only deployments. A blank import of contrib/otelkit-grpc enables it.
My backend isn't a preset. Use PresetCollector(endpoint, transport) (no auth) or set headers/endpoint via WithConfig / OTEL_EXPORTER_OTLP_*. Presets are a convenience, not a requirement.
Nothing reaches my backend and there's no error. That's exactly what otelkit fixes — add WithSelfTest() to fail at startup, or WithDryRun() to print the resolved config. See diagnostics.md.
otelkit is the vendor-neutral, batteries-included bootstrap. The honest landscape (otelconf is the OTEL declarative-config standard — otelkit delegates to it, so they're complementary):
| otelkit | setupOTelSDK (docs snippet) |
otelconf |
Vendor distros (uptrace-go, …) | |
|---|---|---|---|---|
| Vendor-neutral | ✅ | ✅ | ✅ | ❌ backend-locked |
| Vendor presets — 1-line backend swap | ✅ | ❌ | ❌ | only their own |
Owns endpoint/port//v1/ path (no footguns) |
✅ | ❌ | ❌ | partial |
| Loud diagnostics (self-test · probe · dry-run) | ✅ | ❌ | ❌ | ❌ |
Full OTEL_* env compliance |
✅ | partial | ✅ | partial |
Declarative OTEL_CONFIG_FILE |
✅ (delegates to otelconf) |
❌ | ✅ (it is this) | ❌ |
Real no-op on OTEL_SDK_DISABLED |
✅ | ❌ | ❌ | ❌ |
Ordered Shutdown + SIGTERM helper |
✅ | partial | partial | partial |
| All three signals (traces/metrics/logs) | ✅ | ✅ | ✅ | varies |
| Versioned library, not a copy-paste snippet | ✅ | ❌ | ✅ | ✅ |
| Coverage | 100% | n/a | — | varies |
Apache-2.0. See LICENSE.