OpenTelemetry
OpenTelemetry is the kit's observability backbone: every span, metric and exported log record travels through it, and even Sentry is just an extra exporter on top. In the Go ecosystem, distributed tracing is standard, vendor-neutral infrastructure: the kit switches it on without you having to learn the OTel SDK.
otelx: the bootstrap
import "github.com/gp-system/gpsystem/otelx"
func Setup(ctx context.Context, cfg Config, opts ...Option) (*Handle, error)
func (h *Handle) SlogHandler() slog.Handler // OTLP log bridge (nil in dev mode)
func (h *Handle) Shutdown(ctx context.Context) error // flush + stop the providers
func WithSpanExporter(exp sdktrace.SpanExporter) Option // extra span sink beside the primary OTLP one
One call wires:
- Resource:
service.name/service.versionplus host detectors, on every signal. - Tracer, meter and logger providers with OTLP exporters;
Handle.SlogHandler()is the officialotelslogbridge, whichtelemetryweaves into the log fanout, so everyslog.InfoContext(ctx, ...)exports with trace/span IDs. - Propagation: W3C TraceContext + Baggage, so upstream callers' traces continue automatically and yours propagate onward.
You rarely call it directly: the chassis runs it through telemetry.Setup. WithSpanExporter too exists primarily for the telemetry package: that is how Sentry receives the same spans through a second batcher (a nil exporter is silently dropped, so it can be passed unconditionally).
Application code never imports an OTel SDK package: the OTel logs SDK is still pre-1.0, and its version churn stays inside the kit's otelx package, not in your projects. What you do import is the stable go.opentelemetry.io/otel API (see custom spans below).
Configuration: two kit switches, everything else standard
type Config struct {
ServiceName string `env:"OTEL_SERVICE_NAME"` // required outside dev mode
ServiceVersion string `env:"OTEL_SERVICE_VERSION" envDefault:"dev"`
Dev bool `env:"OTEL_DEV_MODE" envDefault:"false"`
}
Exporter endpoints are deliberately not kit configuration. Exporters are selected by contrib/exporters/autoexport from the standard OTel variables, so you configure your deployment like any other OTel-instrumented app: your platform team's OTel knowledge and every vendor's documentation apply unchanged:
OTEL_SERVICE_NAME=shop
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317
# per-signal overrides (default everywhere: otlp):
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp # or: prometheus → scrape endpoint instead of OTLP push
OTEL_LOGS_EXPORTER=otlp
# sampling is OTel-standard too:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.1
In dev mode (OTEL_DEV_MODE=true) no exporters are created at all: traces and metrics are no-ops, there is no OTLP log bridge, and logs go to the console handler. No collector, no anything. One exception: when WithSpanExporter supplies an extra sink (in practice: SENTRY_DSN set in dev mode), a dev-only tracer provider is still built and spans go straight there, collector-free.
What the kit instruments on its own
This is the "detailed metrics" story: you don't write span code, every layer of the kit arrives instrumented.
| Layer | Instrumentation | What you get |
|---|---|---|
| HTTP (Fiber) | the official gofiber/contrib/v3/otel middleware | a server span per request + HTTP metrics (duration, body sizes, active requests) with standard semantic conventions |
| HTTP (chi) | otelhttp.NewHandler around the router | the same on net/http: http.server server spans + http.server.request.duration and friends |
| PostgreSQL (pgx) | the otelpgx tracer on the pool | a span per query, with the SQL (trimmed span name) and timing |
| PostgreSQL (bun) | the bunotel query hook | the same in bun projects, with formatted queries |
| Queue (asynq) | a producer span in Enqueue, a consumer span in the worker; the Envelope carries traceparent | the trace crosses Redis: the listener's span sits inside the originating HTTP request's trace |
| S3 | the otelaws middleware on the AWS SDK | a span per storage call |
| Logs | the otelslog bridge | every ctx-logged record exports with trace/span IDs |
Metrics are collected by the autoexport-built MeterProvider: OTLP push to the collector by default, or a Prometheus scrape endpoint with OTEL_METRICS_EXPORTER=prometheus. For a Grafana dashboard, the HTTP layer's metrics (request duration histogram, active requests) are there immediately, without you writing a single line of measuring code.
Follow the order: a single trace
The shop's POST /api/v1/shop/orders request looks like this in a trace viewer (Jaeger, Grafana Tempo, Sentry, any of them):
POST /api/v1/shop/orders ← HTTP server span (Fiber/chi middleware)
├── INSERT INTO orders ... ← otelpgx, inside the transaction
├── UPDATE inventory SET ... ← otelpgx
├── INSERT INTO outbox ... ← otelpgx (the event commits with the transaction)
└── task event:orderPlaced ← consumer span in the worker: the trace crossed the outbox and Redis
└── enqueue listener:orderPlaced:sendOrderConfirmation ← fan-out producer span
└── task listener:orderPlaced:sendOrderConfirmation ← the listener runs here
└── (the listener's spans: DB, S3, SMTP, whatever its code does)
The key is the queue Envelope: the dispatcher injects a W3C traceparent into it from the request context, the envelope survives the outbox table and Redis, and the worker's consumer span unpacks and continues the trace. (The outbox relay's own producer span belongs to the relay's polling loop, not to this trace: the baton is the envelope itself.) From the HTTP request to the confirmation e-mail, a single trace ID, exactly what the sample app's "follow the order" table promises. If anything fails along the way, the Sentry event links to this trace.
Viewing traces locally
In dev mode, tracing is a no-op by default, but there are two cheap paths when you want to see what the kit produces:
The fastest: dev mode + Sentry
Set a dev Sentry project's DSN alongside OTEL_DEV_MODE=true: the kit then builds a Sentry-only tracer provider, and spans land in Sentry's trace view without a collector. Details: Sentry.
The full picture: a local collector
The generated compose.yml has the otel-collector service commented out. Enable it, drop in the referenced deploy/otel-collector.yaml config (a minimal OTLP receiver + an exporter for your favorite backend, Jaeger, Tempo, anything), then:
OTEL_DEV_MODE=false OTEL_SERVICE_NAME=shop \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
go run ./cmd/shop
From there the service exports exactly as in production: logs included, over OTLP.
Start the worker binary with the same variables (and its own OTEL_SERVICE_NAME, e.g. shop-worker), or you will only see the HTTP half of the "follow the order" trace.
Custom spans and metrics
The kit's principle applies here too: centralize, don't abstract. There is no kit wrapper around the tracing API: where a business-meaningful measuring point is needed, you call the stable OTel API directly:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
)
var tracer = otel.Tracer("github.com/acme/shop/internal/modules/shop")
var meter = otel.Meter("github.com/acme/shop/internal/modules/shop")
func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrder) error {
ctx, span := tracer.Start(ctx, "shop.place_order")
defer span.End()
span.SetAttributes(attribute.Int("order.items", len(in.Items)))
// ... the span is automatically a child of the HTTP server span,
// because you carry the same ctx onward
}
otel.Tracer / otel.Meter work off the global providers that Setup installed. In dev mode the same code is a no-op, no conditionals needed. For counters and histograms it is meter.Int64Counter(...) and friends, the same way. The upstream OTel documentation applies verbatim.
ctx) downward: to repository calls, to slog.*Context, to the dispatcher. Parent-child relationships travel exclusively in the context; a call started with context.Background() breaks out of the trace.Shutdown
Handle.Shutdown stops the providers in reverse order (logs first, since they may reference spans, traces last), and the chassis calls it at the very end of the lifecycle, after all your closers. After a SIGTERM, spans opened during shutdown still reach the collector.
Patterns used
- Isolation layer around the pre-1.0 OTel log SDK (
Handle; application code never imports an OTel SDK package); see Design patterns. - LIFO shutdown via a closer chain: logs stop first, traces last.
OpenTelemetry's Go-specific documentation: opentelemetry.io/docs/languages/go.