telemetry

Overview

The telemetry facade: logging, OpenTelemetry and Sentry in a single call, with one shutdown guarantee.

telemetry is a standalone Go module (github.com/gp-system/telemetry, +telemetry/otelx, +telemetry/sentryx) with no dependency on any logging library: measurement and error reporting, booted by a single call, that takes the default logger's extra sinks as plain slog.Handler options. In a generated project you don't even write that call: the first thing server.Run and worker.Run do is wire the standalone logx module into telemetry.Setup. By the time the shop's main.go starts serving, logging, tracing and error reporting are already wired, before the first request arrives.

Install

go get github.com/gp-system/telemetry@v0.0.0-20260805082731-54141fe347c3 # the main-branch commit the framework pins, no tag published yet
go get github.com/gp-system/telemetry@main
Import pathWhat it adds
github.com/gp-system/telemetrythe facade: Setup composes the two below into one call, plus whatever slog.Handler you pass it
github.com/gp-system/telemetry/otelxthe OpenTelemetry SDK bootstrap
github.com/gp-system/telemetry/sentryxthe optional Sentry integration
github.com/gp-system/logx (+/monolog)the slog composition: console format, level filtering, fanout. A fully standalone module, unrelated to telemetry; see logx: Overview

A binary that only needs one piece can import just that subpackage (or, for logx, the module on its own): a CLI or migrator that wants OTel tracing but no Sentry imports telemetry/otelx alone and never links sentry-go.

The telemetry facade

Two building blocks compose inside telemetry, and logx supplies the console sink from the outside:

Building blockWhat it ownsPage
otelxthe OpenTelemetry SDK bootstrap: traces, metrics, OTLP log bridgeOpenTelemetry
sentryxthe optional Sentry integration: span exporter + slog capture handlerSentry
telemetrythe facade the chassis calls (composes the two above in a single Setup, plus caller-supplied slog.Handlers)this page
logx (+ logx/monolog)the default slog logger's console formatting: console format, level filtering, fanout. A standalone module, not a telemetry dependencylogx: Overview

telemetry.Config is exactly what the server.Config.Telemetry and worker.Config.Telemetry fields hold; the chassis configs hold a separate Log logx.Config field alongside it:

type Config struct {
    Otel   otelx.Config   // OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION, OTEL_DEV_MODE
    Sentry sentryx.Config // SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_TRACES, SENTRY_DEBUG
}

It embeds without a prefix, so the environment variables load under their standard names: a deployment configures the service exactly like any other OTel-instrumented app. The full variable list: Configuration reference.

func Setup(ctx context.Context, cfg Config, opts ...Option) (shutdown func(context.Context) error, err error)

func WithLogHandler(h slog.Handler) Option // adds a leaf to the default logger's fanout
func WithLogLevel(l slog.Level) Option     // gates the whole fanout; default slog.LevelInfo

telemetry composes otelx and sentryx without either knowing about the other, and has no opinion on where else a log record goes: it accepts any slog.Handler through WithLogHandler. This is not cosmetics: a non-chassis binary (a CLI, a migrator) that only uses otelx never links sentry-go, and one that wants no console handler at all never links logx either.

What happens inside Setup

The order matters, because the pieces wire into each other:

  1. Sentry first. sentryx.Setup runs first: without SENTRY_DSN every hook of it is a no-op, and everything behaves exactly as if Sentry did not exist.
  2. OTel, with Sentry's span exporter. otelx.Setup receives Sentry's exporter via the otelx.WithSpanExporter(sentryHandle.SpanExporter()) option: the same spans go to Sentry through an extra batcher, so an error links to the full request trace.
  3. One slog.Default(). The chassis builds a console handler with logx.ConsoleHandler (always in dev, in prod only with LOG_STDOUT=true) and passes it in via WithLogHandler; Setup fans it out alongside the OTLP log bridge and the Sentry capture handler, gates the whole thing with the level from WithLogLevel (LOG_LEVEL), and installs it as the process's default logger. If no handler was passed and neither OTel nor Sentry is active, Setup leaves the previous default logger untouched.

So a single slog.InfoContext(ctx, ...) call in your service is, at once: a readable console line (dev), a trace-correlated log record exported over OTLP (prod), and, on error, a Sentry event with a breadcrumb trail.

What you get with zero configuration

Dev (OTEL_DEV_MODE=true)Prod (default)
Console logalways, readable text (or Monolog format)only with LOG_STDOUT=true, JSON
Traces / metricsno exporters, no collector neededOTLP per the standard OTEL_* variables
OTLP log bridgenonepresent (logs ship to the collector too)
Sentryoff (SENTRY_DSN empty)off (SENTRY_DSN empty)
OTEL_SERVICE_NAMEoptionalrequired (Setup fails without it)
Local development is just OTEL_DEV_MODE=true. The service boots with readable logs and a request logger, no infrastructure required. Add LOG_FORMAT=monolog and the console prints Monolog-style lines familiar from PHP, with clickable stack traces.

There is one exception: if you set SENTRY_DSN in dev mode, the framework builds a Sentry-only tracer provider, so you get full traces and errors in Sentry locally, without a collector; details on the Sentry page.

The flush runs last

Setup returns an aggregated shutdown function that the chassis runs as the Flush step of the application lifecycle, last of all, after the HTTP drain and after all your WithCloser closers. That ordering is deliberate: closers themselves emit telemetry (the log line of a pool close, a span opened during cleanup), and those only make it out if the exporters are still alive.

There is ordering inside the shutdown too: the OTel providers stop first, and Sentry flushes last, so events produced while the providers were shutting down still get out.

If you don't want it, or want to own it

All three chassis have an off switch:

server.Run(ctx, cfg.Server, register, server.WithoutTelemetry())
worker.Run(ctx, cfg.Worker, register, worker.WithoutTelemetry())
realtime.Run(ctx, cfg.Realtime, register, realtime.WithoutTelemetry())

WithoutTelemetry skips telemetry.Setup and the HTTP-side OTel middleware. In tests this is the default posture (no global state fiddling); in production you use it when the process configures its own telemetry: for example, to fit into a pre-existing OTel setup.

In a non-chassis process (a CLI, a one-shot cron-like run) telemetry.Setup can be called directly: you get the same composition, you just defer the shutdown function yourself. If you don't need Sentry either, otelx.Setup stands on its own.

Where to next

  • Logging: the slog composition and the Monolog-format dev console; through PHP eyes, the most striking part.
  • OpenTelemetry: what the framework instruments on its own, and how the shop's "follow the order" trace comes together.
  • Sentry: error reporting with errs-code issue grouping, layered on top of OTel.
Copyright © 2026