Observability

Overview

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

In gpsystem, logging, measurement and error reporting are all booted by a single call, and you don't even write that call: the first thing fiberx.Run, chix.Run and worker.Run do is 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.

The telemetry facade

Three packages each own one capability, and a fourth composes them:

PackageWhat it ownsPage
logx (+ logx/monolog)the default slog logger's composition: console format, level filtering, fanoutLogging
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 other three in a single Setup)this page

telemetry.Config is exactly what the server.Config.Telemetry and worker.Config.Telemetry fields hold:

type Config struct {
    Otel   otelx.Config   // OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION, OTEL_DEV_MODE
    Log    logx.Config    // LOG_LEVEL, LOG_STDOUT, LOG_FORMAT
    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) (shutdown func(context.Context) error, err error)

telemetry is the only package that imports all three building blocks: otelx and sentryx know nothing about each other, and logx has no opinion on where else a log record goes. This is not cosmetics: a non-chassis binary (a CLI, a migrator) that only uses otelx never links sentry-go.

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 console handler (always in dev, in prod only with LOG_STDOUT=true), the OTLP log bridge and the Sentry capture handler are combined into a single handler with logx.Fanout, a logx.LevelFilter(LOG_LEVEL, ...) goes in front, and the whole thing becomes the process's default logger.

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 kit 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:

fiberx.Run(ctx, cfg.Server, register, fiberx.WithoutTelemetry())
chix.Run(ctx, cfg.Server, register, chix.WithoutTelemetry())
worker.Run(ctx, cfg.Worker, register, worker.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 kit 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