Fluxtail
Go logging

Zerolog for Go HTTP Services: JSON Logs and Request Context

Configure Zerolog JSON logs for Go HTTP services. Understand request-local loggers, missing hook fields, context APIs and concurrent request tests.

By Fluxtail Engineering

Zerolog is a structured logging library for Go. Its chainable API builds JSON events with named fields, such as a service name, request ID or error. In an HTTP service, those fields connect messages from the same request even when several requests run at once. The official project documentation covers JSON output, child loggers and hooks.

Missing request fields often come from confusing three things: fields stored on a logger, a logger stored in Go's context.Context, and Go context supplied to an individual log event. Retrieving a logger with zerolog.Ctx(ctx) does not automatically pass ctx to hooks on its events. A hook runs while Zerolog completes an event and can add fields before output is written.

Keep request fields on a child logger created inside the request handler. Supply Go context separately when a hook needs values from it. The examples were tested with Go 1.24.4 and Zerolog v1.35.1. For the standard library alternative, see Golang structured logging with log/slog.

What Zerolog's context methods actually do

API Effect
base.With().Str("request_id", id).Logger() Creates a child logger with a persistent JSON field.
logger.WithContext(ctx) Returns a Go context containing that logger. Keep the returned context.
zerolog.Ctx(ctx) Retrieves the logger stored in that Go context.
logger.Info().Ctx(ctx) Supplies Go context to one event so a hook can read it with GetCtx().
base.With().Ctx(ctx).Logger() Supplies Go context through a child logger to its events.

Event.Ctx, available since v1.30.0, does not serialize Go context. A hook must extract and add the desired value. Context.Ctx also supplies context to events; explicit event context makes that dependency visible at the logging call. See the versioned API documentation.

If no logger is attached, zerolog.Ctx(ctx) returns DefaultContextLogger. When that variable is nil, it returns a disabled logger. Missing attachment can therefore produce silence rather than an error. The versioned context implementation defines this fallback.

Build and test a request-local Zerolog logger

These checks apply to Go HTTP services using Zerolog, especially hooks that read request values. Check the emitted JSON first, then test concurrent requests for mixed fields. These checks examine application behavior; they do not establish collector delivery.

Start with one complete JSON event

In a new module, pin the dependency:

go mod init example.com/zerolog-proof
go get github.com/rs/[email protected]

This program writes an error event to stdout:

package main

import (
    "errors"
    "os"
    "time"

    "github.com/rs/zerolog"
)

func main() {
    zerolog.TimeFieldFormat = time.RFC3339Nano
    logger := zerolog.New(os.Stdout).
        Level(zerolog.InfoLevel).
        With().Timestamp().
        Str("service", "checkout-api").Logger()

    logger.Error().
        Err(errors.New("synthetic checkout failure")).
        Msg("startup-probe")
}

Representative synthetic output; timestamps vary:

{"level":"error","service":"checkout-api","error":"synthetic checkout failure","time":"2026-11-04T22:00:00Z","message":"startup-probe"}

Error() chooses the level, Err(err) adds the error, and Msg() completes the event. Do not reuse a completed event. Configure package-wide settings during startup, before concurrent requests begin.

Reserve time, level, message and error for their built-in roles. Give application fields distinct names. Zerolog does not deduplicate JSON keys, so adding another time field does not reliably replace the first. See the official duplicate-field and concurrency guidance.

Give each request its own logger

Replace the program with this main.go. The local demonstration paths select fixed synthetic IDs. Production services should generate request IDs or validate IDs received from a trusted boundary.

package main

import (
    "context"
    "errors"
    "io"
    "net/http"
    "os"
    "time"

    "github.com/rs/zerolog"
)

type probeKey struct{}
type probeHook struct{}

func (probeHook) Run(e *zerolog.Event, _ zerolog.Level, _ string) {
    if value, ok := e.GetCtx().Value(probeKey{}).(string); ok {
        e.Str("hook_probe", value)
    }
}

func newLogger(out io.Writer) zerolog.Logger {
    return zerolog.New(zerolog.SyncWriter(out)).
        Level(zerolog.InfoLevel).
        With().Timestamp().Str("service", "checkout-api").
        Logger().Hook(probeHook{})
}

func handler(base zerolog.Logger) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id, probe := "req-a", "probe-a"
        if r.URL.Path == "/b" {
            id, probe = "req-b", "probe-b"
        }

        logger := base.With().Str("request_id", id).Logger()
        ctx := context.WithValue(r.Context(), probeKey{}, probe)
        r = r.WithContext(logger.WithContext(ctx))

        zerolog.Ctx(r.Context()).Info().Msg("logger-only")
        zerolog.Ctx(r.Context()).Info().
            Ctx(r.Context()).Msg("event-context")
        w.WriteHeader(http.StatusNoContent)
    })
}

func main() {
    zerolog.TimeFieldFormat = time.RFC3339Nano
    zerolog.DefaultContextLogger = nil
    logger := newLogger(os.Stdout)
    logger.Error().Err(errors.New("synthetic checkout failure")).
        Msg("startup-probe")

    if err := http.ListenAndServe("127.0.0.1:8080", handler(logger)); err != nil {
        logger.Error().Err(err).Msg("server stopped")
    }
}

Run go run ., then send local requests from another terminal:

curl --fail --silent --show-error http://127.0.0.1:8080/a
curl --fail --silent --show-error http://127.0.0.1:8080/b

Each request should emit two events. Both have the child logger's request_id. Only event-context has hook_probe, because that call supplies Go context to the event.

Representative synthetic output, with timestamps omitted:

{"level":"info","service":"checkout-api","request_id":"req-a","message":"logger-only"}
{"level":"info","service":"checkout-api","request_id":"req-a","hook_probe":"probe-a","message":"event-context"}
{"level":"info","service":"checkout-api","request_id":"req-b","message":"logger-only"}
{"level":"info","service":"checkout-api","request_id":"req-b","hook_probe":"probe-b","message":"event-context"}

The typed key separates the demonstration value from unrelated context keys. Real hooks should similarly select safe fields. Do not dump authorization headers, cookies, request bodies or all context values.

Test missing attachment and concurrent requests

Do not call UpdateContext on a logger shared by request handlers. Upstream documents that operation as unsafe for concurrent use. Create request-local children with With(). The output writer must also support concurrent calls; SyncWriter locks the shared destination. A synchronized writer does not make shared logger mutation safe.

Save this as main_test.go. It checks the disabled fallback directly, separately verifies attachment to the buffered logger, then checks both request paths concurrently.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "net/http/httptest"
    "strings"
    "sync"
    "testing"

    "github.com/rs/zerolog"
)

func TestRequestContext(t *testing.T) {
    zerolog.DefaultContextLogger = nil
    var buf bytes.Buffer
    base := newLogger(&buf)

    if zerolog.Ctx(context.Background()).GetLevel() != zerolog.Disabled {
        t.Fatal("unattached logger is enabled")
    }

    ctx := base.WithContext(context.Background())
    zerolog.Ctx(ctx).Info().Msg("attached")
    var attached map[string]any
    if err := json.Unmarshal(buf.Bytes(), &attached); err != nil {
        t.Fatal(err)
    }
    if attached["message"] != "attached" ||
        attached["service"] != "checkout-api" {
        t.Fatal(attached)
    }
    buf.Reset()

    h := handler(base)
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        for _, path := range []string{"/a", "/b"} {
            wg.Add(1)
            go func(path string) {
                defer wg.Done()
                h.ServeHTTP(httptest.NewRecorder(),
                    httptest.NewRequest("GET", path, nil))
            }(path)
        }
    }
    wg.Wait()

    rows := strings.Split(strings.TrimSpace(buf.String()), "\n")
    if len(rows) != 400 {
        t.Fatalf("rows=%d", len(rows))
    }
    counts := map[string]int{}
    for _, line := range rows {
        var row map[string]any
        if err := json.Unmarshal([]byte(line), &row); err != nil {
            t.Fatal(err)
        }
        id := row["request_id"].(string)
        expected := map[string]string{
            "req-a": "probe-a", "req-b": "probe-b",
        }[id]
        if expected == "" {
            t.Fatal(row)
        }
        switch row["message"] {
        case "logger-only":
            if _, ok := row["hook_probe"]; ok {
                t.Fatal(row)
            }
        case "event-context":
            if row["hook_probe"] != expected {
                t.Fatal(row)
            }
        default:
            t.Fatal(row)
        }
        counts[id+"/"+row["message"].(string)]++
    }
    for _, id := range []string{"req-a", "req-b"} {
        for _, msg := range []string{"logger-only", "event-context"} {
            if counts[id+"/"+msg] != 100 {
                t.Fatal(counts)
            }
        }
    }
}

Run the assertions, then the race detector. The second command requires cgo support and a C compiler:

go test ./...
CGO_ENABLED=1 go test -race ./...

The test expects 400 request events: 200 requests, each emitting two messages. Their order can vary. It waits for all handlers before reading the buffer, and every logger writes through the same synchronized writer.

Passing assertions establish the expected field combinations for these inputs. A successful race-detector run adds evidence about the paths exercised. Neither establishes that every handler, hook or writer in a larger application is safe.

Find why a Zerolog field is missing

Check application output before changing collection settings:

  • No event: confirm the chain ends with Msg() or Send(). Check logger and global levels and sampling. Confirm the context returned by WithContext reached the handler.
  • Request ID present, hook field absent: check whether the event received the Go context the hook reads. Verify the typed key and value too.
  • Duplicate field: check whether the logger, event and hook add the same key. Give each field one owner.
  • Another request's ID: look for shared logger mutation or reused request state. Add an isolation assertion for the failing handler.

WithContext carries a logger, zerolog.Ctx retrieves it, and event context gives hooks access to Go values. None automatically converts every context value into JSON.

Check the collected request fields in Fluxtail

Once local JSON contains the expected fields, compare it with centrally received records. This checks collection. It cannot recover fields the application never emitted or establish request isolation inside the Go process.

For containers, forward stdout and stderr through a collector to a Fluxtail receiver. The Docker container logging guide explains that path. Preserve JSON and check how the collector and receiver handle time, level, service and message.

In Fluxtail Live Tail, open the destination stream, narrow by service, and find the test's event-context messages. Inspect received rows for both synthetic pairs:

Request field Hook field
request_id=req-a hook_probe=probe-a
request_id=req-b hook_probe=probe-b

Check the event timestamp and severity too. A value surviving inside a raw message differs from a parsed, mapped field. Inspect rows before depending on custom request-field filters.

If local output is correct but received fields are missing, inspect collector parsing and receiver mapping. If local output already lacks hook_probe, fix application context propagation first. Matching received rows show what arrived; the separate concurrency test examines how those rows were produced.