observability

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

observability-go

OpenTelemetry traces, metrics, and trace-correlated logs for Go services in one call. The Go counterpart of @digiform-by-gs/observability, sharing its environment-variable contract exactly — so a service author moving between the Go, Node, and Next.js stacks configures all three identically.

Requires Go 1.25+ (upstream OTel v1.44 sets that floor).

Quick start

func main() {
    // main owns signal handling — the library deliberately does not install
    // handlers, or it would race http.Server.Shutdown.
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    obs, err := observability.New(ctx, observability.WithServiceName("orders"))
    if err != nil {
        log.Fatal(err)
    }
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        _ = obs.Shutdown(shutdownCtx)
    }()

    logger := obs.Logger()
    // ...
}
OTEL_SERVICE_NAME=orders OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 ./orders

Logging — the one rule

Always use the Context variants. logger.InfoContext(ctx, ...) carries the active span, so the record reaches Loki with a trace_id you can pivot on.

logger.InfoContext(ctx, "order created", slog.String("order_id", id))  // correlated
logger.Info("order created", slog.String("order_id", id))              // NOT correlated

The second line compiles, runs, and raises no error — it just produces a log that can never be joined to its trace. That is why .golangci.yml enables sloglint with context: all, and why it is not optional. Verified: injecting a bare slog.Info builds cleanly and is caught only by the linter.

Keys are snake_case (order_id, not orderId) to match the spelling of resource attributes in Loki (service_name, deployment_environment).

Records also mirror to stdout as JSON, so logs stay visible when the collector is unreachable — the moment you most need them.

Configuration

Precedence is option > environment variable > default.

Env var Option Example Default
OTEL_SERVICE_NAME WithServiceName orders — (required, New errors without it)
OTEL_EXPORTER_OTLP_ENDPOINT WithEndpoint http://localhost:4318 http://localhost:4318
OTEL_SERVICE_VERSION WithServiceVersion 1.4.2 0.0.0
OTEL_DEPLOYMENT_ENVIRONMENT WithEnvironment production development
OTEL_RESOURCE_ATTRIBUTES WithResourceAttributes team=payments,region=eu-west-1 none
OTEL_LOG_LEVEL WithLogLevel info info

Also: WithMetricInterval (default 60s), WithoutRuntimeMetrics, WithoutStdoutLogs.

Instrumenting HTTP

r := chi.NewRouter()
r.Use(otelchi.Middleware("orders", otelchi.WithChiRoutes(r)))          // server

client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)} // client

Use the framework wrapper (otelchi, otelgin), not bare otelhttp, on the server side. Framework wrappers name spans after the route template (GET /orders/{id}); raw otelhttp uses the concrete path (GET /orders/8fe2c4...), which makes span_name unbounded and inflates span-metrics cardinality in Mimir until writes start getting rejected.

otelhttp.NewTransport on the client is what injects traceparent outbound. A bare http.Client produces a disconnected trace at the far end.

What New does that is easy to miss

  1. Sets the global propagator to TraceContext + Baggage. Go's default is a no-op, so without this every service starts a fresh trace — spans look individually correct while nothing ever joins up, and nothing errors. It is the most common Go OTel defect. TestNewSetsTraceContextPropagator guards it.
  2. Builds full signal URLs (…/v1/traces) and passes them explicitly. The SDK also reads OTEL_EXPORTER_OTLP_ENDPOINT and appends the suffix itself, so passing a base URL yields /v1/traces/v1/traces and every export 404s.
  3. Installs an error handler, or export failures are silent and you discover the collector died by noticing empty dashboards.
  4. Enables Go runtime metrics (GC, goroutines, heap) — the best early predictor of a p99 about to get worse.

Differences from the Node package, and why

Node Go
Init --import preload explicit New()
Errors throws returns error
Signals library installs handlers caller owns them
Log correlation main-thread bridge captures ambient context explicit ctx argument

There is no preload entry point and no init-order problem: Go instrumentation is explicit wrapping, not module patching. The globalThis dual-bundle workaround in the Node package's accessors.ts has no analogue here and should not be ported.

Testing

go test ./...
golangci-lint run --config ../../.golangci.yml ./...

Documentation

Overview

Package observability wires OpenTelemetry traces, metrics, and logs to an OTLP/HTTP collector with one call, mirroring the env-var contract of the @digiform-by-gs/observability Node package.

Unlike the Node package there is no preload entry point, because Go instrumentation is explicit wrapping rather than module patching — the init-order problem simply does not exist here. Wrap your handlers and clients; nothing is monkey-patched behind your back.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Meter

func Meter(name string, opts ...metric.MeterOption) metric.Meter

Meter returns a named meter from the global provider.

Create instruments once at package or struct level, never per request, and keep attribute values low-cardinality — every distinct combination is a separate series, and Mimir now rejects writes past the configured limits.

func Tracer

func Tracer(name string, opts ...trace.TracerOption) trace.Tracer

Tracer returns a named tracer from the global provider — the analogue of the Node package's getTracer().

These read the global providers rather than the Observability struct, so packages deep in your service can obtain a tracer without threading the struct through every constructor. They are safe to call before New(): you get a no-op that starts working once New() registers the real providers.

Types

type Config

type Config struct {
	ServiceName        string
	ServiceVersion     string
	Environment        string
	Endpoint           string
	ResourceAttributes map[string]string
	MetricInterval     time.Duration
	LogLevel           string

	// Headers are sent on every OTLP export request (all three signals) —
	// e.g. {"Authorization": "Bearer <key>"} for an authenticated collector.
	//
	// Deliberately NOT resolved from OTEL_EXPORTER_OTLP_HEADERS here: the
	// exporters read that env var themselves, including the per-signal
	// OTEL_EXPORTER_OTLP_TRACES_HEADERS overrides. Parsing it in the wrapper
	// would shadow that spec behaviour.
	//
	// DIVERGENCE FROM NODE — setting this REPLACES any headers that came from
	// OTEL_EXPORTER_OTLP_HEADERS rather than merging with them, because
	// upstream's otlpconfig.WithHeaders assigns the map outright. The Node
	// package's exporters merge. Use one mechanism or the other, never both:
	// mixing them silently drops the env-only headers in Go and keeps them in
	// Node. Guarded by TestOTLPHeadersOptionReplacesEnvironment.
	Headers map[string]string

	// DisableRuntimeMetrics turns off Go runtime instrumentation (GC pauses,
	// goroutine count, heap). Left on by default: those series are the single
	// best predictor of a p99 that is about to get worse.
	DisableRuntimeMetrics bool

	// DisableStdoutLogs stops mirroring log records to stdout. The mirror
	// exists so logs remain visible when the collector is unreachable.
	DisableStdoutLogs bool
}

Config is the resolved configuration. Field-for-field this mirrors ResolvedConfig in packages/observability/src/types.ts so that a service author moving between the Go and Node stacks configures them identically.

type Observability

type Observability struct {
	// contains filtered or unexported fields
}

Observability owns the SDK providers. Hold it in main and defer Shutdown.

func New

func New(ctx context.Context, opts ...Option) (*Observability, error)

New initialises the SDK and registers the global providers.

It deliberately does NOT install signal handlers. main() owns signal.NotifyContext; a library grabbing SIGTERM fights http.Server.Shutdown and produces truncated shutdowns that are miserable to debug.

func (*Observability) Config

func (o *Observability) Config() Config

Config exposes the resolved configuration (useful for logging what was actually picked up at boot).

func (*Observability) Logger

func (o *Observability) Logger() *slog.Logger

Logger returns the slog.Logger bridged to the OTel Logs API.

Use the Context-suffixed methods — InfoContext, ErrorContext, ... — or the record carries no trace_id. Plain logger.Info compiles and runs and silently produces an uncorrelated log; the sloglint rule in .golangci.yml exists to catch that, and is not optional.

func (*Observability) Shutdown

func (o *Observability) Shutdown(ctx context.Context) error

Shutdown flushes all three signals. Call it with a bounded context: a dead collector must not hold your process open.

type Option

type Option func(*Config)

Option mutates configuration before defaults are resolved. Options take precedence over environment variables, which take precedence over defaults — the same order as the Node package's resolveConfig.

func WithEndpoint

func WithEndpoint(v string) Option

func WithEnvironment

func WithEnvironment(v string) Option

func WithHeaders

func WithHeaders(headers map[string]string) Option

WithHeaders merges extra headers onto every OTLP export request. Mirrors the Node package's `headers` option. Call it more than once and the maps merge.

func WithLogLevel

func WithLogLevel(v string) Option

func WithMetricInterval

func WithMetricInterval(d time.Duration) Option

func WithResourceAttributes

func WithResourceAttributes(attrs map[string]string) Option

WithResourceAttributes merges additional resource attributes. Values here win over anything parsed from OTEL_RESOURCE_ATTRIBUTES.

func WithServiceName

func WithServiceName(v string) Option

func WithServiceVersion

func WithServiceVersion(v string) Option

func WithoutRuntimeMetrics

func WithoutRuntimeMetrics() Option

func WithoutStdoutLogs

func WithoutStdoutLogs() Option

Directories

Path Synopsis
Package amqp carries OpenTelemetry context across RabbitMQ, which is the one place cross-service correlation has to be built rather than configured.
Package amqp carries OpenTelemetry context across RabbitMQ, which is the one place cross-service correlation has to be built rather than configured.
httpx module
Package redisx wires go-redis to OpenTelemetry.
Package redisx wires go-redis to OpenTelemetry.
Package sqlx wires database/sql to OpenTelemetry via XSAM/otelsql.
Package sqlx wires database/sql to OpenTelemetry via XSAM/otelsql.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL