Skip to content

Logging

Core declares the logging interfaces: gas.Logger, gas.LogEvent and gas.LoggerContext. Every service, and core itself, logs against them. gas/log provides the implementations, so the backend is a wiring decision and application code never imports it:

Backend Built on Notes
NewZeroLogLogger() rs/zerolog JSON, every level including Trace. Uses zerolog’s global logger unless WithZeroLogInstance gives another.
NewSlogLogger() log/slog No dependency beyond the standard library. Trace logs at slog’s Debug level. Uses slog.Default() unless WithSlogInstance gives another.
NewShippingLogger() log/slog plus HTTP Writes locally and sends records in batches to an HTTP endpoint.
Terminal window
go get github.com/gasmod/gas/log

The package name is log; the examples import it as gaslog.

main.go
// RequestLogger is the per-request logger type. gas.Logger stays a
// singleton; RequestLogger is scoped and derived from it.
type RequestLogger interface{ gas.Logger }
func main() {
app := gas.NewApp(
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithScopedService[RequestLogger](func(l gas.Logger) RequestLogger {
return l.With().Logger()
}),
gas.WithSingletonService[*Notes](NewNotes),
)
if err := app.Run(); err != nil {
log.Fatal(err)
}
}

Each backend function returns a constructor with no parameters, not a logger, so the container builds it without resolving anything. Register it as the gas.Logger singleton: most services take a gas.Logger in their constructor, and the container rejects a singleton that depends on a scoped or transient service. Per-request fields go on a separate scoped type derived from it, RequestLogger above, which the request logger fills for each request.

See Without a logger for what happens when none is registered.

// zerolog writes JSON and supports every level, Trace included.
func zerologBackend() gaslog.ZeroLogLoggerCtor {
zl := zerolog.New(os.Stdout).With().Timestamp().Logger()
return gaslog.NewZeroLogLogger(gaslog.WithZeroLogInstance(&zl))
}
// slog's default logger drops Debug records, so give it a handler at the
// level you want. Trace logs at slog's Debug level.
func slogBackend() gaslog.SlogLoggerCtor {
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
return gaslog.NewSlogLogger(gaslog.WithSlogInstance(slog.New(h)))
}

With no options, NewSlogLogger logs through slog.Default(), which drops Debug records unless its level has been lowered, so Trace and Debug calls produce nothing. Pass WithSlogInstance with a handler at the level you want.

The backends map the gas.Logger levels as shown in Levels.

func fluent(logger gas.Logger) {
logger.Info("note created").
Str("note_id", "n-42").
Int("size", 512).
Bool("pinned", true).
Send()
// With derives a new logger with persistent fields; logger is unchanged.
notes := logger.With().Str("service", "notes").Logger()
notes.Debug("cache miss").Str("key", "notes:n-42").Send()
}

A level method (Trace, Debug, Info, Warn, Error) starts an event, field methods add to it, and Send() emits it; an event that is never sent logs nothing. The field methods on an event and on With() are the same: Str, Int, Int64, Float64, Bool, Err, Duration and Any.

With() returns a builder; its Logger() returns a new logger that carries the fields on every record. The logger With was called on is never changed, so a singleton shared by every request stays as it was. Logging through any backend is safe for concurrent use.

Flush() writes buffered records. zerolog and a plain slog logger buffer nothing, so for them it does nothing; on a shipping logger it posts the queue and waits.

// chi's RequestID runs first, so the request logger can add request_id.
// RequestLogger[T] resolves T from the request scope, derives a logger with
// the request ID, puts it on the request context and, because T is scoped,
// in the scope too.
s.router.UseMiddlewareFunc(middleware.RequestID)
s.router.UseMiddlewareFunc(gas.RequestLogger[RequestLogger]())
s.router.UseMiddlewareFunc(attachUser)
s.router.Get(s, "/notes", listNotes)
// listNotes takes the request's logger as a dependency; it carries
// request_id.
func listNotes(ctx gas.Context, logger RequestLogger) error {
logger.Info("listing notes").Send()
return ctx.JSON(http.StatusOK, []string{})
}

gas.RequestLogger[T]() is core middleware. For each request it resolves T from the request scope and derives a per-request logger from it with With, adding a request_id field from chi’s RequestID middleware, which must be mounted before it. gas.WithRequestLoggerAppendRequestID(false) leaves the field out, and gas.WithRequestLoggerBuilder(fn) lets you add fields or swap the logger after that. If T cannot be resolved, the middleware passes the request through and logs nothing.

When the request finishes, the middleware logs one request record with the method, path, status and duration. See The request logger for the details.

gas.WithLogger(ctx, logger) returns a context carrying a logger, and gas.LoggerFromContext(ctx) returns it, or nil when there is none. A middleware mounted after the request logger can build on the logger it stored and store the result back:

// attachUser adds a field for everything downstream that logs through
// gas.LoggerFromContext. It builds on the logger RequestLogger put on the
// context and stores the result back there; no logger is changed in place.
func attachUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := gas.LoggerFromContext(r.Context())
if base == nil {
next.ServeHTTP(w, r)
return
}
logger := base.With().Str("user_id", r.Header.Get("X-User-Id")).Logger()
next.ServeHTTP(w, r.WithContext(gas.WithLogger(r.Context(), logger)))
})
}

Code that logs through gas.LoggerFromContext(r.Context()) then gets both request_id and user_id. The scoped RequestLogger instance is not updated by this middleware, so a handler that takes RequestLogger gets request_id only.

// Info and above is written locally as JSON on stderr; Warn and above is
// also shipped in batches. Registered in the container, the logger is closed
// at shutdown, which posts what is still queued.
func shippingLogger(endpoint, apiKey string) gas.Option {
local := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})
return gas.WithSingletonService[gas.Logger](gaslog.NewShippingLogger(
endpoint,
gaslog.NewOTLPMarshaler(
gaslog.WithServiceName("notes"),
gaslog.WithServiceVersion("1.4.2"),
),
gaslog.WithHeader("Authorization", "Bearer "+apiKey),
gaslog.WithLevel(slog.LevelWarn),
gaslog.WithLocalHandler(local),
gaslog.WithErrorHandler(func(err error) {
log.Printf("log shipping: %v", err)
}),
))
}

NewShippingLogger(endpoint, marshaler, opts...) builds on the slog backend. Each record goes to the local handler when it is at or above that handler’s level, and to a queue when it is at or above the shipping level. A background goroutine posts the queue to endpoint in batches, one POST per batch, encoded by the marshaler. NewOTLPMarshaler encodes OTLP/HTTP JSON logs.

Option Default Notes
WithBatchSize(n) 100 records A batch is posted when it reaches this size. Values that are not positive are ignored.
WithQueueSize(n) 1024 records Records waiting for delivery. Values that are not positive are ignored.
WithFlushInterval(d) 2s A non-empty batch is posted at least this often. Values that are not positive are ignored.
WithHTTPClient(c) a client with a 10s timeout nil is ignored.
WithLevel(l) slog.LevelInfo Minimum level shipped, and the level of the default local handler. nil is ignored.
WithLocalHandler(h) JSON on stderr at the shipping level Replaces the local handler, with its own level.
WithoutLocalHandler() Ships only.
WithHeader(k, v) Sent on every batch request, for example an API key.
WithErrorHandler(fn) none Receives delivery failures.
WithName(name) gas/log-shipping The gas.Service name. An empty name is ignored.

Delivery is best-effort: a full queue drops records and a failed post never reaches the code that logged. See Delivery.

// The same pipeline for code that uses slog directly. Close posts what is
// queued and stops the delivery goroutine.
func shipSlog(endpoint string) (closeFn func() error) {
h, closer := gaslog.NewShippingHandler(endpoint, gaslog.NewOTLPMarshaler(
gaslog.WithServiceName("notes"),
))
slog.SetDefault(slog.New(h))
return closer.Close
}

NewShippingHandler takes the same arguments and options and returns the same local-plus-shipping pipeline as an slog.Handler, with an io.Closer that drains and stops it like Close above.

Any other wire format is a Marshaler: Marshal([]Record) ([]byte, error) encodes one batch into one request body, and ContentType() is sent as the Content-Type header. A Record has the Time, Level, Message and Attrs of the event, including the fields added with With().

// ndjson sends a batch as newline-delimited JSON objects.
type ndjson struct{}
func (ndjson) ContentType() string { return "application/x-ndjson" }
func (ndjson) Marshal(records []gaslog.Record) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
for _, r := range records {
line := map[string]any{"time": r.Time, "level": r.Level.String(), "msg": r.Message}
for _, a := range r.Attrs {
line[a.Key] = a.Value.Any()
}
if err := enc.Encode(line); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}

The module ships no mock package. To assert on output, give the slog backend a handler over a buffer; where output does not matter, core’s gas.NopLogger discards everything without allocating (gas.NewNopLogger() is its constructor for the container).

notes_test.go
// Capture output by giving the slog backend a handler over a buffer. Where
// output does not matter, &gas.NopLogger{} discards everything.
func TestFluentLogsNoteCreated(t *testing.T) {
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
logger, err := gaslog.NewSlogLogger(gaslog.WithSlogInstance(slog.New(h)))()
if err != nil {
t.Fatal(err)
}
fluent(logger)
if !strings.Contains(buf.String(), `"msg":"note created"`) {
t.Fatalf("log output %q lacks the record", buf.String())
}
fluent(&gas.NopLogger{})
}

gas/log reads no configuration keys from gas.ConfigProvider; every setting is an option passed to the constructor.

The package declares no sentinel errors, and its constructors never return an error. Shipping failures are only ever passed to WithErrorHandler, with these messages:

Message When
marshal log batch: ... The marshaler returned an error. The batch is dropped.
build log request: ... The request could not be built, for example from an invalid endpoint URL.
ship logs: ... The HTTP request failed.
ship logs: unexpected status <code> The endpoint answered with a status of 300 or more.

The sections above cover everyday use. This section is the full contract, for when an exact edge case matters.

If no gas.Logger is registered at all, Gas logs its own lifecycle messages through a logger over slog.Default() and logs a no logger registered warning once. That fallback is not registered in the container, so a service that takes a gas.Logger still fails to resolve.

gas.Logger method zerolog level slog level OTLP severity number
Trace TraceLevel LevelDebug 5
Debug DebugLevel LevelDebug 5
Info InfoLevel LevelInfo 9
Warn WarnLevel LevelWarn 13
Error ErrorLevel LevelError 17

The per-request logger goes on the request context with gas.WithLogger. When T is scoped, as RequestLogger is here, it also replaces T’s instance in the request scope, so handlers and services that take T later in the request get the logger with request_id. A singleton T is never replaced.

When the request finishes, the middleware logs a request record with method, path, status, bytes, duration and remote: at error level for a status of 400 or more, at info level otherwise. See Middleware for where it sits in the chain.

Delivery is best-effort by design, so logging can never stall or fail the code that logs:

  • A record is queued without blocking. When the queue is full, it is dropped silently.
  • A batch is posted when it reaches the batch size, when the flush interval passes, on Flush, and on Close.
  • Marshal errors, transport errors and responses with a status of 300 or more go to the WithErrorHandler callback, never to the code that logged. Without a callback they are discarded.
  • The local handler sees each record first. If it returns an error, the record is not shipped, and Send drops the error.

The shipping logger implements gas.Service. Its goroutine starts when it is constructed, so it works with or without the container, and its Init does nothing. Close, which the container runs at shutdown, posts the records still queued, stops the goroutine and returns once that last post has finished, so it can take as long as the HTTP client’s timeout; it ignores its context and can be called more than once. After Close, the local handler still writes, records meant for shipping are dropped, and Flush returns at once. Outside the container, call Flush() or Close before the process exits.

A sub-logger made with With() shares the same handlers and sender, so its records are shipped and its Flush posts and waits like the parent’s.

NewOTLPMarshaler sends one OTLP logs request per batch with Content-Type: application/json. WithServiceName sets the service.name resource attribute and, unless WithScopeName sets one, the instrumentation scope name; WithServiceVersion sets service.version, and WithResourceAttribute(key, value) adds any other resource attribute. Each record carries timeUnixNano, severityText (DEBUG, INFO, WARN, ERROR), severityNumber from the table above, the message as its body, and its fields as attributes. Durations and times are sent as strings.