otel

package module
v0.0.0-...-66c5ff6 Latest Latest
Warning

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

Go to latest
Published: Mar 7, 2026 License: MIT Imports: 26 Imported by: 0

README

go-otel

A thin, opinionated OpenTelemetry setup library for Go services. One call wires up traces, metrics, and logs over OTLP/HTTP, returns a pre-configured *slog.Logger, and exposes HTTP middleware for http.ServeMux.

Requirements

Installation

go get github.com/bitsmithy/go-otel

Quick start

import (
    "context"
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    telemetry "github.com/bitsmithy/go-otel"
)

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    shutdown, log, tracer, meter, err := telemetry.Setup(ctx, telemetry.Config{
        Endpoint: "http://localhost:4318",
    })
    if err != nil {
        slog.Error("telemetry init failed", "error", err)
        os.Exit(1)
    }
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        _ = shutdown(shutdownCtx)
    }()

    // log, tracer, and meter are ready to use.
    log.InfoContext(ctx, "service started")
    _ = tracer
    _ = meter
}

Configuration

All fields are optional — sensible defaults are derived from build info when omitted.

telemetry.Config{
    // service.name resource attribute.
    // Default: last segment of the module path (e.g. "myservice" from "github.com/acme/myservice").
    ServiceName: "myservice",

    // service.namespace resource attribute.
    // Default: penultimate segment of the module path (e.g. "acme").
    ServiceNamespace: "acme",

    // service.version resource attribute.
    // Default: Go module version from build info, or "unknown".
    ServiceVersion: "1.2.3",

    // Full URL of the OTLP collector. Passed to all three signal exporters.
    // Default: read from OTEL_EXPORTER_OTLP_ENDPOINT (or per-signal variants).
    Endpoint: "http://otel-collector:4318",

    // Minimum log level written to stderr.
    // Default: slog.LevelInfo.
    LogLevel: slog.LevelDebug,
}
Endpoint precedence
Source Takes precedence
Config.Endpoint (in code) Highest
OTEL_EXPORTER_OTLP_<SIGNAL>_ENDPOINT env var Per-signal override
OTEL_EXPORTER_OTLP_ENDPOINT env var Fallback
localhost:4318 SDK default

Per-signal env vars (OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, OTEL_EXPORTER_OTLP_LOGS_ENDPOINT) still work when Config.Endpoint is empty, so you can route signals to different collectors via the environment without changing code.

What Setup returns

shutdown func(context.Context) error  // flushes and closes all exporters
log      *slog.Logger                 // fans out to OTel log pipeline + stderr JSON
tracer   trace.Tracer                 // scoped to ServiceName
meter    metric.Meter                 // scoped to ServiceName
err      error

Call shutdown on the way out — it flushes buffered spans, metrics, and log records. Pass a context with a timeout to cap the flush time:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdown(shutdownCtx)

HTTP middleware

NewMiddleware creates an HTTP middleware that automatically instruments every route:

Signal Instrument Description
Trace Server span One span per request, named "METHOD /route/pattern"
Metric http.server.request.count Int64Counter — total requests
Metric http.server.request.duration Float64Histogram — latency in seconds
Metric http.server.active_requests Int64UpDownCounter — in-flight requests

All metrics carry http.request.method, http.route, and http.response.status_code attributes. The route label is the pattern (e.g. /users/{id}), not the concrete path — requires Go 1.22+ http.ServeMux.

mw, err := telemetry.NewMiddleware(tracer, meter)
if err != nil {
    log.Error("middleware init failed", "error", err)
    os.Exit(1)
}

mux := http.NewServeMux()
mux.HandleFunc("/users/{id}", usersHandler)
mux.HandleFunc("/health",     healthHandler)

server := &http.Server{
    Addr:    ":8080",
    Handler: mw.Wrap(mux), // wrap the entire mux
}

Incoming W3C traceparent / tracestate headers are automatically extracted, so distributed traces propagate through the service boundary without any extra code.

Logging

Setup returns a *slog.Logger that fans out to two sinks:

  • OTel log pipeline — records are exported to the collector via OTLP
  • stderr — JSON-formatted records, filtered at Config.LogLevel

Both sinks are backed by TraceHandler, which automatically appends trace_id and span_id to every record when an active span is present in the context. Use log.InfoContext(ctx, ...) (not log.Info(...)) to get trace correlation.

ctx, span := tracer.Start(ctx, "process-order")
defer span.End()

log.InfoContext(ctx, "order received", "order_id", 42)
// stderr output includes: {"msg":"order received","order_id":42,"trace_id":"...","span_id":"..."}
Additional logger instances

If you need a second logger (e.g. for a background worker), call NewLogger after Setup:

workerLog := telemetry.NewLogger("worker", slog.LevelWarn)

Using the tracer

Use the tracer to record units of work as spans. Child spans are created by passing the context returned by the parent Start call.

func processOrder(ctx context.Context, tracer trace.Tracer, orderID int) error {
    ctx, span := tracer.Start(ctx, "process-order",
        trace.WithAttributes(attribute.Int("order.id", orderID)),
    )
    defer span.End()

    if err := validateOrder(ctx, tracer, orderID); err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, "order validation failed")
        return err
    }

    span.SetStatus(codes.Ok, "")
    return nil
}

func validateOrder(ctx context.Context, tracer trace.Tracer, orderID int) error {
    _, span := tracer.Start(ctx, "validate-order")
    defer span.End()

    // ... validation logic ...
    return nil
}

Spans are automatically linked — validate-order will appear as a child of process-order in your tracing backend.

Span attributes and events
ctx, span := tracer.Start(ctx, "upload-file")
defer span.End()

// Attach structured data to the span.
span.SetAttributes(
    attribute.String("file.name", filename),
    attribute.Int64("file.size_bytes", size),
)

// Record a point-in-time event within the span.
span.AddEvent("virus-scan-passed")

// Mark the span as failed.
if err != nil {
    span.RecordError(err)
    span.SetStatus(codes.Error, "upload failed")
}

Required imports:

import (
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

Using the meter

Use the meter to create and record metric instruments. Create instruments once (e.g. in a constructor) and reuse them across calls.

Counter — count occurrences
type OrderProcessor struct {
    orderCount metric.Int64Counter
}

func NewOrderProcessor(meter metric.Meter) (*OrderProcessor, error) {
    orderCount, err := meter.Int64Counter("orders.processed",
        metric.WithDescription("Total number of orders processed"),
        metric.WithUnit("{order}"),
    )
    if err != nil {
        return nil, err
    }
    return &OrderProcessor{orderCount: orderCount}, nil
}

func (p *OrderProcessor) Process(ctx context.Context, status string) {
    p.orderCount.Add(ctx, 1, metric.WithAttributes(
        attribute.String("order.status", status),
    ))
}
Histogram — measure distributions
duration, err := meter.Float64Histogram("order.processing.duration",
    metric.WithDescription("Time taken to process an order"),
    metric.WithUnit("s"),
)

start := time.Now()
err = processOrder(ctx, orderID)
duration.Record(ctx, time.Since(start).Seconds(), metric.WithAttributes(
    attribute.Bool("order.success", err == nil),
))
Gauge — track a current value
queueDepth, err := meter.Int64UpDownCounter("orders.queue.depth",
    metric.WithDescription("Number of orders waiting to be processed"),
    metric.WithUnit("{order}"),
)

queueDepth.Add(ctx, 1)  // order enqueued
queueDepth.Add(ctx, -1) // order dequeued

Required imports:

import (
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/metric"
)

Using the logger

Always use the Context variants (InfoContext, ErrorContext, etc.) so that trace_id and span_id are automatically injected when a span is active.

// Structured key-value pairs.
log.InfoContext(ctx, "order received", "order_id", orderID, "customer", email)

// Warn with context.
log.WarnContext(ctx, "payment retry", "attempt", 2, "order_id", orderID)

// Error — include the error value as "error".
if err != nil {
    log.ErrorContext(ctx, "order failed", "order_id", orderID, "error", err)
    return err
}

Example stderr output when a span is active:

{"time":"2026-03-06T14:00:00Z","level":"INFO","msg":"order received","order_id":99,"customer":"alice@example.com","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7"}
Child loggers

Use With to create a sub-logger that carries shared fields across all calls — useful for request-scoped or component-scoped logging:

reqLog := log.With("request_id", requestID, "user_id", userID)
reqLog.InfoContext(ctx, "handler started")
reqLog.InfoContext(ctx, "handler complete", "status", 200)

Telemetry after context cancellation

When a request context is cancelled (e.g. a timeout fires), the OTel SDK silently drops any telemetry emitted on that context. Use DetachedContext to emit final logs and metrics after a potentially-cancelled operation:

start := time.Now()
result, err := doWork(ctx)
dur := time.Since(start).Seconds()

// ctx may be cancelled here — detach before recording telemetry.
tctx := telemetry.DetachedContext(ctx)
if err != nil {
    log.ErrorContext(tctx, "work failed", "duration_s", dur, "error", err)
    return
}
log.InfoContext(tctx, "work done", "duration_s", dur)

DetachedContext returns a fresh, never-cancelled context that still carries the active span, so trace correlation is preserved.

slog handlers

Both handlers are exported for use in custom logging setups.

TraceHandler

Wraps any slog.Handler and injects trace_id and span_id into records when an active span is present:

base := slog.NewJSONHandler(os.Stderr, nil)
log := slog.New(&telemetry.TraceHandler{Handler: base})
FanoutHandler

Sends each record to multiple handlers. Useful for routing logs to more than one sink:

log := slog.New(telemetry.FanoutHandler{
    slog.NewJSONHandler(os.Stderr, nil),
    slog.NewTextHandler(logFile, nil),
})

License

MIT — see LICENSE.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DetachedContext

func DetachedContext(ctx context.Context) context.Context

DetachedContext returns a fresh context that is never cancelled but carries the active span from ctx.

Use this when emitting telemetry after an operation that may have timed out: a cancelled ctx causes the OTel SDK to silently drop log records and metric data points.

func NewLogger

func NewLogger(serviceName string, level slog.Level) *slog.Logger

NewLogger builds a *slog.Logger that fans out to the OTel log pipeline and stderr JSON. The stderr handler is wrapped with TraceHandler so trace_id and span_id are automatically injected into every record.

Call this after Setup if you need an additional logger instance, or use the one returned by Setup directly.

func Setup

func Setup(ctx context.Context, cfg Config) (
	shutdown func(context.Context) error,
	log *slog.Logger,
	tracer trace.Tracer,
	meter metric.Meter,
	err error,
)

Setup initialises the three OTel pillars (traces, metrics, logs), sets the global providers, and returns a shutdown func, a pre-wired *slog.Logger, and the tracer and meter for the named service.

OTLP endpoint and headers are read from the standard OTel environment variables (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS, etc.).

Typical usage:

shutdown, log, tracer, meter, err := telemetry.Setup(ctx, telemetry.Config{})
if err != nil { ... }
defer shutdown(ctx)

Types

type Config

type Config struct {
	// ServiceName is the OTel service.name resource attribute.
	// Defaults to the last path segment of the module path (e.g. "alchemist").
	ServiceName string

	// ServiceNamespace is the OTel service.namespace resource attribute.
	// Use this to group services belonging to the same system or team.
	// Defaults to the penultimate path segment of the module path (e.g. "bitsmithy").
	ServiceNamespace string

	// ServiceVersion is the OTel service.version resource attribute.
	// Defaults to the Go module version from build info, or "unknown".
	ServiceVersion string

	// Endpoint is the full URL of the OTLP collector, e.g. "http://localhost:4318".
	// When set it is passed as WithEndpointURL to all three exporters (traces, metrics, logs).
	// When empty the standard OTel environment variables are used as a fallback
	// (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, etc.).
	Endpoint string

	// LogLevel controls the minimum level written to stderr.
	// Defaults to slog.LevelInfo.
	LogLevel slog.Level
}

Config describes the service being instrumented. All fields are optional — sensible defaults are applied for anything left empty.

type FanoutHandler

type FanoutHandler []slog.Handler

FanoutHandler sends each slog.Record to all inner handlers. It supports any number of sinks and propagates errors from each handler.

func (FanoutHandler) Enabled

func (f FanoutHandler) Enabled(ctx context.Context, level slog.Level) bool

func (FanoutHandler) Handle

func (f FanoutHandler) Handle(ctx context.Context, r slog.Record) error

func (FanoutHandler) WithAttrs

func (f FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler

func (FanoutHandler) WithGroup

func (f FanoutHandler) WithGroup(name string) slog.Handler

type Middleware

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

Middleware holds the tracer and pre-registered HTTP metric instruments. Construct one with NewMiddleware and reuse it across the application.

func NewMiddleware

func NewMiddleware(tracer trace.Tracer, meter metric.Meter) (*Middleware, error)

NewMiddleware creates a Middleware from explicit tracer and meter instances (as returned by Setup). It registers the HTTP metric instruments once.

func (*Middleware) Wrap

func (m *Middleware) Wrap(next http.Handler) http.Handler

Wrap returns an http.Handler that automatically instruments every route registered on next. The span name and http.route label are read from r.Pattern (set by http.ServeMux in Go 1.22+ after routing).

Because r.Pattern is only known after the mux has matched the route, the span name and metric labels are finalised after next.ServeHTTP returns.

type TraceHandler

type TraceHandler struct {
	slog.Handler
}

TraceHandler wraps an slog.Handler and injects trace_id and span_id into each log record when an active OTel span is present in ctx.

func (*TraceHandler) Handle

func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error

func (*TraceHandler) WithAttrs

func (h *TraceHandler) WithAttrs(attrs []slog.Attr) slog.Handler

func (*TraceHandler) WithGroup

func (h *TraceHandler) WithGroup(name string) slog.Handler

Jump to

Keyboard shortcuts

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