Fluxtail
Go logging

Logrus Guide: JSON Logs, Request Fields and Blocking Hooks

Configure Logrus JSON logs and request fields, measure synchronous hook delays, and verify Docker-collected records and severity in Fluxtail.

By Fluxtail Engineering

Logrus is a structured logging library for Go. It lets applications attach named fields to events, choose severity levels and send records through formatters and hooks. Those features help maintain an existing service without replacing its logging calls.

The maintainers describe Logrus as being in maintenance mode: work focuses on security, bug fixes and performance, with limited exceptions for interoperability. That does not mean it is abandoned. For new applications, compare the separate guides to Go’s standard log/slog package and Zerolog. Logrus maintainer documentation

One behavior matters when maintaining Logrus services: a hook runs in the calling goroutine. A hook is code invoked for selected log levels, often to enrich or forward an event. If it waits for a remote service, the logging call waits too. The application does not automatically get a background worker or timeout. Logrus hook implementation

JSON formatting and hook execution solve different problems. JSON makes records easier to parse. It does not prevent a slow hook or output writer from delaying a request.

Configure compact JSON and explicit levels

Create a logger once, configure it before serving requests, and derive entries for individual requests:

logger := logrus.New()
logger.SetOutput(os.Stdout)
logger.SetFormatter(&logrus.JSONFormatter{
    TimestampFormat: time.RFC3339Nano,
})
logger.SetLevel(logrus.InfoLevel)

requestLog := logger.WithFields(logrus.Fields{
    "service":    "checkout-api",
    "request_id": "demo-request-17",
    "route":      "/probe",
})
requestLog.Info("request started")

These statements belong inside a Go function with os, time and github.com/sirupsen/logrus imported. The complete runnable example below includes them.

Keep JSON on one physical line per event. Pretty printing spreads an event across lines and complicates collection. Logrus supplies time, level and msg; use separate names for application fields. Reserve error for WithError. Avoid relying on the formatter’s collision handling to repair an ambiguous schema. JSON formatter source

At InfoLevel, Debug and Trace events are suppressed. Use Info for normal milestones, Warn for unexpected conditions that the service handles, and Error for failed operations. Fatal exits the process after logging; Panic raises a panic. Neither is an ordinary request-error level. See log severity levels for cross-library differences.

Check hook delays and collected JSON

For an existing Logrus service, check both the time spent inside a logging call and the records stored after collection. The following checks separate those questions. A timing measurement can reveal a blocking hook. Finding a stored marker confirms arrival of that record, but cannot establish request latency or complete delivery.

Measure the logging call

This small program invokes an HTTP handler locally without opening a listening port. Each run emits three numbered Info markers and one synthetic Error event. Set SLOW_HOOK=1 to add a hook that sleeps for 50 milliseconds on each Info event.

In a new project directory, initialize the module and pin the demonstrated dependency:

go mod init example.com/logrus-probe
go get github.com/sirupsen/[email protected]

Save this as main.go:

package main

import (
    "errors"
    "fmt"
    "net/http"
    "os"
    "time"

    "github.com/sirupsen/logrus"
)

type delayHook struct{ delay time.Duration }

func (h delayHook) Levels() []logrus.Level {
    return []logrus.Level{logrus.InfoLevel}
}

func (h delayHook) Fire(*logrus.Entry) error {
    time.Sleep(h.delay)
    return nil
}

func main() {
    logger := logrus.New()
    logger.SetOutput(os.Stdout)
    logger.SetFormatter(&logrus.JSONFormatter{
        TimestampFormat: time.RFC3339Nano,
    })
    logger.SetLevel(logrus.InfoLevel)
    if os.Getenv("SLOW_HOOK") == "1" {
        logger.AddHook(delayHook{50 * time.Millisecond})
    }

    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestLog := logger.WithContext(r.Context()).WithFields(logrus.Fields{
            "service":    "checkout-api",
            "request_id": "demo-request-17",
            "route":      "/probe",
        })
        for seq := 1; seq <= 3; seq++ {
            start := time.Now()
            requestLog.WithField("seq", seq).Info("logrus-probe-17")
            fmt.Fprintf(w, "seq=%d logging_call_us=%d\n",
                seq, time.Since(start).Microseconds())
        }
        requestLog.WithError(errors.New("synthetic inventory timeout")).
            Error("dependency check failed")
    })

    req, _ := http.NewRequest(http.MethodGet, "http://localhost/probe", nil)
    handler.ServeHTTP(stdoutResponse{}, req)
}

// Keep diagnostic timings on stderr and application JSON on stdout.
type stdoutResponse struct{}

func (stdoutResponse) Header() http.Header { return http.Header{} }
func (stdoutResponse) WriteHeader(int)     {}
func (stdoutResponse) Write(b []byte) (int, error) {
    return os.Stderr.Write(b)
}

Run the same path with and without the hook:

go run . > baseline.jsonl 2> baseline-timing.txt
SLOW_HOOK=1 go run . > delayed.jsonl 2> delayed-timing.txt
cat baseline-timing.txt delayed-timing.txt

One local run with Go 1.24.4 on Linux/amd64 and Logrus 1.9.4 produced:

# Without the hook
seq=1 logging_call_us=84
seq=2 logging_call_us=5
seq=3 logging_call_us=4
# With the 50 ms hook
seq=1 logging_call_us=50296
seq=2 logging_call_us=50181
seq=3 logging_call_us=50174

These are local demonstration results, not a general benchmark. The timer includes field derivation, the Info call, hooks, formatting and the output write. It excludes compilation, process startup and remote collection. The deliberate sleep explains the roughly 50 ms increase in this example. Scheduler and filesystem behavior will change individual measurements.

The hook selects only Info, so the Error event does not take this hook’s sleep. Repeat measurements around the same enabled level when testing a real hook.

For a network hook, inspect its actual connection, request and retry timeouts. WithContext makes a context available to hooks and formatters; it does not force a hook to observe cancellation. Moving work into goroutines also needs explicit queue limits, a policy for full queues and shutdown handling. An unbounded queue merely moves the failure elsewhere. Entry context implementation

A collector can move remote forwarding outside the application’s logging call. The stdout write can still block, so measure the real deployment rather than treating stdout as a latency guarantee.

Keep request fields separate

The example creates requestLog inside the handler. In a real service, assign or validate a request identifier for each request; the fixed identifier above is only a reproducible marker. Pass the derived entry to functions handling that request. Do not replace a shared global entry’s request fields or mutate its Data map while other requests use it.

Use WithFields to derive entries and WithError for an error value. WithContext(r.Context()) does not automatically turn context values into JSON fields. Request IDs still need an explicit field. Avoid mutable maps or slices as field values unless their contents remain unchanged while logging.

Representative synthetic application output looks like this; timestamps vary:

{"level":"info","msg":"logrus-probe-17","request_id":"demo-request-17","route":"/probe","seq":1,"service":"checkout-api","time":"2026-11-05T18:17:08.851461176Z"}
{"error":"synthetic inventory timeout","level":"error","msg":"dependency check failed","request_id":"demo-request-17","route":"/probe","service":"checkout-api","time":"2026-11-05T18:17:08.851563919Z"}

WithError records the error text here, not an automatic stack trace. Error strings can contain secrets or personal data. Prefer safe application errors and allowlisted fields such as route patterns, request IDs and operation names. Do not attach request bodies, authorization headers or whole user objects by default.

Verify collected markers in Fluxtail

For a Docker deployment using JSON log files, follow the official Fluent Bit Docker collection guide. It tails Docker’s log files, adds container details and sends batches to a shared HTTP JSON receiver. Create a receiver-bound token with ingest:write, use the exact receiver URL, and keep the token in an untracked, access-restricted environment file. The documented setup mounts the log directory read-only, avoids the Docker socket and keeps a filesystem retry buffer.

Run the probe inside the container with application stdout connected to Docker logging. The earlier shell redirects are for local timing inspection; redirecting stdout to a file inside the container would bypass that collection path. The probe also writes timing text to stderr, so expect separate diagnostic records during this test. Remove the probe’s timing output from normal application operation.

Docker’s JSON log file is an outer record containing the application’s output as text. Parsing that outer record does not, by itself, establish that the inner Logrus JSON has been parsed and mapped. Check the received event before relying on application fields for searches or alerts.

In Live Tail, select the intended stream and find logrus-probe-17 in the test time window. Open a matching event and check:

  • Whether request_id, service and numeric seq appear as separate fields or remain inside message text.
  • Whether markers numbered 1, 2 and 3 are visible for the intended test run.
  • Whether the synthetic Error event has Error severity in the received record.

The last check matters because the documented Docker path defaults stdout to INFO and stderr to ERROR when no severity exists. This example writes both Info and Error application events to stdout. If the nested Logrus level is not mapped, the Error event may receive the stdout fallback. Confirm the collector’s parsing and level mapping before building severity-based alerts. Docker collection behavior

Finding a marker confirms that record was stored and is readable by your account. Missing markers require checking the time window, stream, receiver, collector diagnostics and local retention. Visible markers do not prove that every event arrived, and their timestamps do not replace the logging-call timer. The Live Tail documentation explains the event inspector; the Docker container logs guide covers the collection context.

Keep logging behavior testable

Before changing a production hook or formatter, test an Info event, an Error event and two requests with different identifiers. Check that fields stay with the correct request, JSON remains one line per event, and the received severity matches the application level. Measure slow and failed forwarding separately from successful forwarding.

Keep the local timing result, application JSON and received event as separate evidence. Together they show what the application emitted, how long the call took and what the log store retained.