Skip to content
gohacks.dev
Web

Request ID Middleware and Correlated Logs in Go Services

Write a request ID middleware in Go: generate or accept an ID safely, keep it in the context, return it, log it and pass it to outbound calls.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

When a user reports that something failed, the first question is which request it was. In a service handling a few hundred requests a second, "around 14:03, the checkout page" is a haystack. A request ID is the needle: a short unique value generated when the request arrives, attached to every log line that request produces, returned to the client in a header, and passed to every service the request calls. One search for that value finds the whole story across the web server, the worker that picked up its job and the downstream API that timed out.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds the middleware that does it, with the two decisions that deserve care: where the ID comes from (generate or trust the caller) and how it travels (context inside the process, a header between processes). It builds on middleware, feeds logging middleware and recovery, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.

Where the ID comes from

Two designs exist, and a hybrid is common. Always generate: every request gets a fresh ID at the edge, and any inbound header is ignored. It is simple and cannot be abused. Accept the caller's: if a load balancer, an API gateway or an upstream service already assigned an ID (usually X-Request-ID), reuse it, so the ID is the same from the edge to the deepest service. That is what makes correlation work across systems, and it is why the hybrid is the practical choice: trust the header when it is present and well-formed, generate when it is not.

The "well-formed" check matters because the header is client-controlled. An unchecked value can be megabytes long, can contain newlines followed by a fake log line, can contain terminal escape sequences, and can be chosen by an attacker to collide with another request's ID. Accept only a bounded length and a safe alphabet. A request ID is a correlation tool, never a secret and never a security token.

For the generated form, 8 random bytes in hex (16 characters) give 2^64 possibilities, which is far more than enough for correlation. UUIDs, ULIDs and UUIDv7 are fine and longer; a time-ordered ID sorts nicely in logs. crypto/rand is the right source because math/rand outputs repeat across processes seeded the same way, and the cost of a random read is negligible.

The middleware

requestid.goGo
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"log/slog"
	"net/http"
	"regexp"
)

type ctxKey int

const (
	idKey ctxKey = iota
	loggerKey
)

const Header = "X-Request-ID"

// validID is the shape we are willing to accept from a caller: short, printable, no spaces or control characters.
var validID = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)

func newID() string {
	b := make([]byte, 8)
	rand.Read(b) // crypto/rand.Read never returns an error on supported platforms
	return hex.EncodeToString(b)
}

// RequestID assigns an ID to every request, stores it in the context and echoes it in the response.
func RequestID(base *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := r.Header.Get(Header)
			if !validID.MatchString(id) {
				id = newID() // missing, or not a value we trust
			}
			w.Header().Set(Header, id) // set before the handler writes, so every response carries it

			logger := base.With("request_id", id)
			ctx := context.WithValue(r.Context(), idKey, id)
			ctx = context.WithValue(ctx, loggerKey, logger)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// IDFrom returns the request ID stored in ctx, or "" outside a request.
func IDFrom(ctx context.Context) string {
	id, _ := ctx.Value(idKey).(string)
	return id
}

// LoggerFrom returns the request's logger, which already carries the request ID.
func LoggerFrom(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
		return l
	}
	return slog.Default()
}

Notes on the details. The context keys are an unexported type, so no other package can read or overwrite them (context values). The per-request logger is built once, here, with base.With("request_id", id), and every handler that calls LoggerFrom(r.Context()) gets lines that already carry the ID, so the handler author cannot forget it. LoggerFrom returns the default logger when no middleware ran, so unit tests of handlers need no setup. The header is set on the response before calling next, because headers are locked once the handler writes, which means error responses, 404s from the mux and panics converted to 500s all carry the ID.

Propagating to other services

The ID is only as useful as its reach. When the handler calls another service, the outbound request should carry the same header, so that service's logs line up with yours. The least intrusive way to do that is a RoundTripper wrapper on the HTTP client, which adds the header from the request's context to every call without any change at the call sites:

transport.goGo
package main

import "net/http"

// propagate copies the request ID from the outgoing request's context onto its header.
type propagate struct{ next http.RoundTripper }

func (p propagate) RoundTrip(req *http.Request) (*http.Response, error) {
	if id := IDFrom(req.Context()); id != "" {
		req = req.Clone(req.Context()) // RoundTrippers must not modify the caller's request
		req.Header.Set(Header, id)
	}
	return p.next.RoundTrip(req)
}

// NewClient returns an HTTP client that forwards the request ID.
func NewClient() *http.Client {
	return &http.Client{Transport: propagate{http.DefaultTransport}}
}

Two rules apply to any RoundTripper: clone the request before changing it, since the contract forbids modifying the caller's request, and the context on the outgoing request must be the inbound request's context (http.NewRequestWithContext(r.Context(), ...)), which is the discipline that also gives you cancellation and deadlines (request context). A call made with context.Background() carries no ID, so one grep over the codebase for http.Get( and context.Background() in handlers finds the gaps.

The whole chain at work

The demo starts a downstream service and an edge service. The edge handler logs a line, calls the downstream through the propagating client, and returns the ID in its header. Three requests: no inbound ID, a valid inbound ID, and a hostile one with a forged log line:

main.goGo
package main

import (
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
)

func main() {
	base := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	mw := RequestID(base)

	downstream := httptest.NewServer(mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		LoggerFrom(r.Context()).Info("downstream handling")
		fmt.Fprint(w, "downstream ok")
	})))
	defer downstream.Close()

	client := NewClient()
	edge := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		LoggerFrom(r.Context()).Info("edge handling")
		req, _ := http.NewRequestWithContext(r.Context(), "GET", downstream.URL, nil) // the context carries the ID
		resp, err := client.Do(req)
		if err != nil {
			http.Error(w, "upstream failed", http.StatusBadGateway)
			return
		}
		defer resp.Body.Close()
		io.Copy(w, resp.Body)
	}))

	for _, inbound := range []string{"", "trace-from-the-gateway-42", "x\nINFO forged log line user=admin"} {
		req := httptest.NewRequest("GET", "/", nil)
		if inbound != "" {
			req.Header.Set(Header, inbound)
		}
		rec := httptest.NewRecorder()
		edge.ServeHTTP(rec, req)
		fmt.Printf("inbound %-38q -> response ID %q (replaced: %v)\n", inbound, rec.Header().Get(Header), rec.Header().Get(Header) != inbound)
	}
}
Text
level=INFO msg="edge handling" request_id=2e9457064fd70cd6
level=INFO msg="downstream handling" request_id=2e9457064fd70cd6
inbound ""                                     -> response ID "2e9457064fd70cd6" (replaced: true)
level=INFO msg="edge handling" request_id=trace-from-the-gateway-42
level=INFO msg="downstream handling" request_id=trace-from-the-gateway-42
inbound "trace-from-the-gateway-42"            -> response ID "trace-from-the-gateway-42" (replaced: false)
level=INFO msg="edge handling" request_id=7b132326c221ce87
level=INFO msg="downstream handling" request_id=7b132326c221ce87
inbound "x\nINFO forged log line user=admin"   -> response ID "7b132326c221ce87" (replaced: true)

Look at the log lines the run printed: for each request the edge and the downstream lines carry the same request_id, which is the property you want when you search. A request with no ID got a generated one; the gateway's valid ID was kept and appeared in both services' logs; the hostile value was rejected (the response ID differs from what was sent), so the forged "INFO forged log line" never reached a log. Tracing systems (OpenTelemetry, with traceparent) generalize this: the trace ID plays the role of the request ID, spans give the structure, and when you have one you can attach its trace ID to log lines in the same way and skip the separate header.

Including the ID in errors

The ID helps most at the moment a user hits a problem, so show it to them. Error responses should include it in the body: "Something went wrong. Quote this reference if you contact support: 3f9a1c20d4e7b6a8". Support pastes the value into the log search and finds the stack trace, and you never need to ask the user for a timestamp. For a JSON API, add a field to the error shape (writing JSON responses):

Go
writeProblem(w, http.StatusInternalServerError, "internal error", map[string]any{"request_id": IDFrom(r.Context())})

The recovery middleware should read the ID too, so a panic's log line has it (recovery), which means RequestID goes just inside recovery, or recovery takes the ID from the response header it set.

Tests

requestid_test.goGo
package main

import (
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func run(inbound string) (ctxID, headerID string) {
	h := RequestID(slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctxID = IDFrom(r.Context())
	}))
	req := httptest.NewRequest("GET", "/", nil)
	if inbound != "" {
		req.Header.Set(Header, inbound)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return ctxID, rec.Header().Get(Header)
}

func TestGeneratesWhenMissing(t *testing.T) {
	ctxID, headerID := run("")
	if len(ctxID) != 16 || ctxID != headerID {
		t.Fatalf("ctx %q, header %q: want a 16 character ID, identical in both", ctxID, headerID)
	}
}

func TestKeepsAWellFormedInboundID(t *testing.T) {
	if ctxID, _ := run("gateway-id-12345"); ctxID != "gateway-id-12345" {
		t.Fatalf("the inbound ID was replaced: %q", ctxID)
	}
}

func TestRejectsAHostileInboundID(t *testing.T) {
	for _, bad := range []string{"short", strings.Repeat("a", 65), "has space in it", "line\nbreak-1234", "emoji-\U0001F600-1234"} {
		if ctxID, _ := run(bad); ctxID == bad {
			t.Errorf("accepted the hostile ID %q", bad)
		}
	}
}

func TestOutboundCallsCarryTheID(t *testing.T) {
	var seen string
	downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r.Header.Get(Header) }))
	defer downstream.Close()
	h := RequestID(slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		req, _ := http.NewRequestWithContext(r.Context(), "GET", downstream.URL, nil)
		NewClient().Do(req)
	}))
	req := httptest.NewRequest("GET", "/", nil)
	req.Header.Set(Header, "caller-supplied-id")
	h.ServeHTTP(httptest.NewRecorder(), req)
	if seen != "caller-supplied-id" {
		t.Fatalf("the downstream service saw %q", seen)
	}
}
Text
=== RUN   TestGeneratesWhenMissing
--- PASS: TestGeneratesWhenMissing (0.00s)
=== RUN   TestKeepsAWellFormedInboundID
--- PASS: TestKeepsAWellFormedInboundID (0.00s)
=== RUN   TestRejectsAHostileInboundID
--- PASS: TestRejectsAHostileInboundID (0.00s)
=== RUN   TestOutboundCallsCarryTheID
--- PASS: TestOutboundCallsCarryTheID (0.00s)
PASS
ok  	example.test/lab	1.320s

The hostile-ID test is a table of the inputs that make logs unsafe or unbounded: too short to be useful, too long, with a space, with a newline, with a non-ASCII character. The propagation test is the end-to-end proof that the transport wrapper works. For standards, the slog documentation covers With and handlers, and the W3C Trace Context specification defines the traceparent header that distributed tracing uses instead of a home-grown ID.

Also inDevOps
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.