telemetry

package module
v0.0.2 Latest Latest
Warning

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

Go to latest
Published: Jun 2, 2026 License: MIT Imports: 2 Imported by: 0

README

go-telemetry

Opinionated OpenTelemetry bootstrap for Go services. One Init call wires logs, traces, and metrics against the same OTLP collector and returns a bundle of handles.

Built and maintained by Timewave.

Install

go get github.com/Timewave-AB/go-telemetry

Usage

Bootstrap
import (
    "context"

    "github.com/Timewave-AB/go-telemetry"
)

func main() {
    ctx := context.Background()
    tel, err := telemetry.Init(ctx, telemetry.Options{
        Level:          "info",
        OTLPEndpoint:   "otel-collector:4317", // empty disables OTLP entirely
        ServiceName:    "queue-worker",
        ServiceVersion: "", // empty → debug.ReadBuildInfo()
        Transport:      telemetry.TransportGRPC, // default; or TransportHTTP
    })
    if err != nil {
        panic(err)
    }
    defer tel.Shutdown(ctx)

    tel.Logger.Info("started", "workers", 4)
}
Spans, with a logger that auto-correlates

tel.Tracer.Start returns the child context and a *SpanLogger bound to the new span. log.Info(...) calls through it are tagged with the span's trace_id and span_id — no need to thread the context through every log call. End the span via log.Span().End().

func handleLogin(ctx context.Context, tel *telemetry.Telemetry, username string) error {
    ctx, log := tel.Tracer.Start(ctx, "login")
    defer log.Span().End()

    log = log.With("username", username)
    log.Info("user is trying to login")

    if err := authenticate(ctx, username); err != nil {
        log.Span().RecordError(err)
        log.Error("login failed", "err", err)
        return err
    }

    log.Info("login succeeded")
    return nil
}

Nested spans work the same way — pass the returned ctx to the next Start, and each per-span logger correlates to its own span:

ctx, log := tel.Tracer.Start(ctx, "request")
defer log.Span().End()
log.Info("received")

ctx, inner := tel.Tracer.Start(ctx, "db.query")
defer inner.Span().End()
inner.Info("querying")  // tagged with the inner span

The top-level tel.Logger is a separate *Logger type with no span binding — it always emits with context.Background(). Use it for process-wide messages (startup, shutdown, periodic stats) where there is no active span.

Joining an incoming trace

When a reverse-proxy (or any upstream) sends a W3C traceparent, tel.Tracer.Extract reads it from the incoming carrier and returns a context. The next Start then makes the service's span a child of the upstream span, in the same trace — so the service shows up under the proxy's request in Tempo/Grafana instead of in a detached trace. A missing or invalid header falls back to a fresh trace; Extract never errors.

import (
    "net/http"

    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/trace"
)

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := tel.Tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
    ctx, log := tel.Tracer.Start(ctx, "GET /thing", trace.WithSpanKind(trace.SpanKindServer))
    defer log.Span().End()
    log.Info("handling request")
}

Extract uses the composite propagator (TraceContext + Baggage) also exposed at tel.OTel().Propagator — it just saves you wiring the extraction by hand. It accepts any propagation.TextMapCarrier; wrap an http.Header with propagation.HeaderCarrier, or a map[string]string with propagation.MapCarrier.

Metrics
jobs, _ := tel.Meter.Int64Counter("jobs.processed")
jobs.Add(ctx, 1)

tel.Meter is the standard OpenTelemetry metric.Meter — no wrapper.

Configuration
Field Required Default Notes
ServiceName yes Emitted as service.name on every signal.
ServiceVersion no ReadBuildInfo"unknown" Emitted as service.version.
OTLPEndpoint no "" "" disables OTLP entirely (stdout-only logs, noop traces/metrics).
Transport no TransportGRPC TransportGRPC (port 4317) or TransportHTTP (port 4318).
OTLPSecure no false Set true to use TLS to the collector.
Level no info error/warn/info/verbose/debug (case-insensitive).
TraceSampleRatio no 0 (always) 0 → always sample; (0,1] → ratio-based sampling.
MetricExportInterval no SDK default (60s) How often metrics are pushed.
LogExporter no OTLP Override the log exporter (tests, non-OTLP backends).
TraceExporter no OTLP Override the trace exporter.
MetricExporter no OTLP Override the metric exporter.
OnError no nil Receives async SDK errors (exporter failures, dropped batches) and multi-handler write errors.

If OTLPEndpoint has no port, the default port for the chosen transport is filled in automatically. Setting a per-signal exporter override enables that signal even when OTLPEndpoint is empty.

Log format

Stdout always receives plain text, even with OTLP enabled:

2026-05-24 14:02:11 [INFO] msg="started" workers=4
  • Timestamps are UTC.
  • Levels render as ERROR, WARN, INFO, VERBOSE, DEBUG.
  • Attributes are sorted alphabetically per record.
  • Groups become dotted prefixes: req.id="abc".
  • The format is fixed by design — every service using this package looks the same in kubectl logs/docker logs.

Log levels

Five levels in slog's "higher value = more important" ordering:

Name slog.Level
error 8
warning 4
info 0
verbose -2
debug -4

Empty Level defaults to info silently; an unknown value emits one stderr warning and falls back to info.

No OTel globals

Init never calls otel.SetTracerProvider, SetMeterProvider, SetLoggerProvider, or SetTextMapPropagator. Multiple Init calls in the same process are independent.

The bundle's providers and propagator are reachable via tel.OTel() for wiring third-party instrumentation libraries that ask for them:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

h := tel.OTel()
client := &http.Client{
    Transport: otelhttp.NewTransport(http.DefaultTransport,
        otelhttp.WithTracerProvider(h.TracerProvider),
        otelhttp.WithPropagators(h.Propagator)),
}

Most callers never need tel.OTel() — the day-to-day handles (Logger, Tracer, Meter) cover normal use.

The one exception to "no globals": if Options.OnError is set, Init installs otel.SetErrorHandler so async SDK errors reach you. Opt-in only — leaving OnError nil keeps all globals untouched.

Last-resort global registration

If you discover a dependency that reads otel.GetTracerProvider() unconditionally and silently drops spans, opt in at your main:

h := tel.OTel()
otel.SetTracerProvider(h.TracerProvider)
otel.SetTextMapPropagator(h.Propagator)

Never call these inside library code — they belong in main.

Reaching the underlying handles
  • tel.Logger.Slog() / spanLog.Slog() return the underlying *slog.Logger for libraries that take a stdlib logger directly.
  • tel.Tracer.OTel() returns the underlying trace.Tracer.
  • spanLog.Span() returns the bound trace.Span — used to end the span, record errors, set attributes, etc.

Behavioural contract

  • OTLPEndpoint == "" and no exporter overrides: logs print to stdout; traces/metrics use noop providers. No exporters are created, no goroutines started.
  • OTLPEndpoint != "" (or any per-signal exporter override): logs fan out to stdout and the configured exporter; a downstream failure on one record does not suppress stdout for that record.
  • tel.Flush(ctx) runs ForceFlush on all enabled providers under the caller's ctx. Safe to call any number of times.
  • tel.Shutdown(ctx) flushes and tears down providers under the caller's ctx — pick a deadline that matches your environment. Calling it twice is a no-op.

Development & testing

Everything runs in Docker — no local Go toolchain needed.

./run-tests.sh

This builds a golang:1.25-alpine image, mounts the working tree into it, and runs go vet ./... followed by go test -race ./.... The module cache and build cache live in a named volume (go-cache), so subsequent runs are fast.

The same script runs in CI on pull requests and pushes to main (.github/workflows/test.yml).

Documentation

Overview

Package telemetry is an opinionated OpenTelemetry bootstrap for Go services. One Init call wires logs, traces, and metrics against the same OTLP collector and returns a bundle of handles.

The implementation lives in internal/core; this file re-exports the public surface via type aliases so callers depend only on this package.

Index

Constants

View Source
const (
	TransportGRPC = core.TransportGRPC
	TransportHTTP = core.TransportHTTP
)

Transport selects the OTLP wire protocol.

View Source
const (
	LevelDebug   = core.LevelDebug
	LevelVerbose = core.LevelVerbose
	LevelInfo    = core.LevelInfo
	LevelWarning = core.LevelWarning
	LevelError   = core.LevelError
)

Log levels. Five levels in slog's "higher value = more important" ordering.

Variables

This section is empty.

Functions

This section is empty.

Types

type Logger

type Logger = core.Logger

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

type OTelHandles

type OTelHandles = core.OTelHandles

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

type Options

type Options = core.Options

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

type SpanLogger

type SpanLogger = core.SpanLogger

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

type Telemetry

type Telemetry = core.Telemetry

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

func Init

func Init(ctx context.Context, opts Options) (*Telemetry, error)

Init bootstraps logs, traces, and metrics. See Options for configuration.

type Tracer

type Tracer = core.Tracer

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

type Transport

type Transport = core.Transport

Type aliases preserve methods, field access, and identity — callers see exactly the types they had before the impl moved under internal/.

Directories

Path Synopsis
internal

Jump to

Keyboard shortcuts

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