Observability
Everything here uses the standard library and OpenTelemetry. There is no vendor SDK to swap out: point the OTLP exporter at whatever backend you run.
Both services log JSON lines with log/. Each line inside a request carries request_id, and trace_id and span_id when a trace is active, so you can go from a log line to its trace and back.
LOG_LEVELsets the level (debug,info,warn,error; defaultinfo).LOG_FORMATisjson(default) ortext, which is easier to read in a terminal.
Every HTTP request gets one line, request, with method, pattern, status, bytes, duration and the request ID, plus err when the handler failed. A 5xx logs at error level, and a client that left before the response at debug. The route pattern, such as /, is logged instead of the raw path, so IDs in paths don’t explode your log index. The gateway’s access log keeps the path without its query string, so the sign-in codes in mailed links stay out of it.
Request IDs. An incoming X-Request-Id is kept if it is 1 to 64 characters of letters, digits, ., _ or -. Otherwise the service makes a new one. Either way it is echoed in the response and in every problem body.
Redaction. The handler replaces the values of keys that hold credentials with [redacted]: password, secret, token, access_token, refresh_token, authorization, cookie, set-cookie, client_secret, code and code_verifier. Secrets in config use a Secret type that prints [redacted] too.
Traces and metrics
Section titled “Traces and metrics”When OTEL_EXPORTER_OTLP_ENDPOINT is set, or one of its per-signal variants, the services export traces and metrics over OTLP/HTTP. When it isn’t, the providers are no-ops and cost nothing. These are the OpenTelemetry SDK’s standard variables, so any collector works.
Incoming HTTP requests get a span named after the method and route, such as GET /. Trace context propagates with the W3C traceparent header and baggage.
Packages record their own metrics with the global meter. The outbox relay, for example, counts delivered and buried messages:
func (r *Relay) init() { r.initOnce.Do(func() { r.Config = r.Config.withDefaults() if r.Logger == nil { r.Logger = slog.Default() } if r.Now == nil { r.Now = time.Now } meter := otel.Meter("github.com/vardius/go-api-boilerplate/internal/platform/outbox") // The no-op meter never fails, and a failing real one returns a // usable no-op counter, so these errors carry nothing to act on. r.delivered, _ = meter.Int64Counter("outbox.delivered", metric.WithDescription("Messages delivered.")) r.buried, _ = meter.Int64Counter("outbox.buried", metric.WithDescription("Messages given up on after repeated failures.")) })}Locally, the compose.observability.yaml override adds grafana/, which bundles a collector, Grafana and storage for logs, traces and metrics, and points both services at it. Grafana is then on http:/:
make up OBSERVABILITY=1Health probes
Section titled “Health probes”| Route | Answers |
|---|---|
/ |
200 while the process runs. |
/ |
200 when every readiness check passes and the service isn’t shutting down. Otherwise 503, with a problem body naming the checks that failed or saying the service is draining. A check’s error is logged, not sent. |
The readiness checks cover what the service needs to serve, such as its database. Kubernetes probes these routes; compose runs <service> healthcheck, which asks the local /, because the distroless image has no shell or curl.
Shutdown
Section titled “Shutdown”On SIGINT or SIGTERM a service:
- flips
/toreadyz 503, so load balancers stop sending it traffic; - waits
SHUTDOWN_DELAY(default5s) for them to notice; - shuts down every server and worker concurrently, allowing
SHUTDOWN_TIMEOUT(default20s) for requests in flight to finish; - exits non-zero if anything failed to stop cleanly.