Skip to content

Structured logging

Core declares gas.Logger as a fluent interface. gas/log supplies backends that implement it, so application code logs against one API and the backend is a wiring decision.

Terminal window
go get github.com/gasmod/gas/log
Backend Library Notes
NewZeroLogLogger() rs/zerolog Fast structured JSON, full level support including Trace
NewSlogLogger() log/slog Zero extra dependencies. Trace maps to Debug
NewShippingLogger() log/slog plus HTTP Writes locally and batches records to an endpoint

Each returns a constructor function, not a logger, so it drops straight into the container.

main.go
// A singleton logger is shared. Registering it scoped instead gives each
// request its own instance, which is what lets middleware attach request
// fields without leaking them across requests.
func register() []gas.Option {
return []gas.Option{
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithScopedService[gas.Logger](gaslog.NewZeroLogLogger()),
}
}
func fluent(logger gas.Logger) {
logger.Info("request handled").
Str("method", "GET").
Str("path", "/api/notes").
Int("status", 200).
Send()
// With branches into a new logger carrying persistent fields.
sub := logger.With().Str("service", "notes").Logger()
sub.Debug("cache miss").Send()
}

Every event ends with Send(). Fields available on events, on With(), and on SetBaseFields(): Str, Int, Int64, Float64, Bool, Err, Duration, and Any.

With() branches into a new logger, which is wrong for middleware, since the handler downstream holds the original. SetBaseFields() mutates in place instead, so everything logged later in that request carries the fields, even from code that never saw the middleware.

// SetBaseFields mutates the receiver instead of branching, so everything
// logged later in the same request carries these fields, including from
// handlers that never saw this middleware.
func attachRequestFields(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := gas.MustResolveFromRequestScope[gas.Logger](r)
logger.SetBaseFields().
Str("user_id", "u-123").
Str("trace_id", r.Header.Get("X-Trace-Id")).
Apply()
next.ServeHTTP(w, r)
})
}
// The built-in request logger records method, path, status, bytes, duration
// and remote address. Responses at 400 and above log at error level.
func requestLogging(router *gas.Router) {
router.UseMiddlewareFunc(gas.RequestLogger[gas.Logger]())
}

Logs method, path, status, bytes written, duration, and remote address for every request, at error level for statuses at 400 and above. If chi’s RequestID middleware is mounted upstream, the request id is attached automatically; pass gas.WithRequestLoggerAppendRequestID(false) to turn that off.

// Ships every record to an HTTP endpoint as well as writing locally. Delivery
// is best-effort: records are dropped rather than blocking the caller, and
// failures go to WithErrorHandler instead of the logging call site.
func shipping(endpoint, apiKey string) gas.Logger {
return gaslog.NewShippingLogger(
endpoint,
gaslog.NewOTLPMarshaler(
gaslog.WithServiceName("notes"),
gaslog.WithServiceVersion("1.4.2"),
),
gaslog.WithHeader("X-API-Key", apiKey),
gaslog.WithBatchSize(100),
)()
}

Records are captured by an slog.Handler, batched, and delivered by a background goroutine. The wire format is a pluggable Marshaler, with an OTLP/HTTP JSON one included.

Delivery is best-effort by design: when the queue is full, records are dropped rather than blocking the code that logged them, and delivery failures go to WithErrorHandler rather than surfacing at the call site. Logging should not be able to stall or fail a request.

The shipping logger implements gas.Service, so registering it in the container means Close drains buffered records at shutdown. Outside the container, call Flush() before exit.

gas.WithLogger(ctx, logger) puts a logger on a context and gas.LoggerFromContext(ctx) takes it back out, returning nil when absent. Useful for code far from the handler that should still log with request fields attached.

gas.Logger Zerolog Slog
Trace TraceLevel LevelDebug
Debug DebugLevel LevelDebug
Info InfoLevel LevelInfo
Warn WarnLevel LevelWarn
Error ErrorLevel LevelError

If no logger is registered at all, Gas falls back to a slog-backed one and warns once, so startup diagnostics are never silently lost.