Go Middleware Chains and Structured Logging for Audit-Trail Pipelines
The chain begins where the seed lives; every entry signs the one before.
§ IFrame
Today's Ops lesson names the audit log as one of the three runtime trust primitives. The audit log is the application's job — eBPF watches the kernel, Falco watches the rules, and the application itself writes the action history that gives the auditor a future answer to a future question. In Go, the audit log is built from three idiomatic primitives that compose into one decorator chain: the http.Handler interface for middleware composition, the context.Context for request-scoped data, and the slog package for structured logging.
This lesson takes the audit-log emission surface from this morning's Ops lesson and shows how a Go service writes it. The pattern is the same whether the agent is an LLM-driven orchestrator, a market-data ingestor, or a sidecar proxy. The middleware chain is where the policy lives; the slog Handler is where the format lives; the hash-chain wrapper is where the trust lives.
§ IILanguage Idiom
Go's middleware chain is a decorator pattern expressed through interface composition. The standard library defines http.Handler as a one-method interface: ServeHTTP(w ResponseWriter, r *Request). A middleware is a function with signature func(http.Handler) http.Handler — it takes a handler and returns a new one that wraps it. Chains compose by application: outer(middle(inner(finalHandler))). The wrapping is left-to-right syntactically and outer-to-inner at execution.
The context package gives the middleware chain its memory. context.Context carries request-scoped values, deadlines, and cancellation signals. A middleware can attach a value via ctx = context.WithValue(ctx, key, value), swap the request: r = r.WithContext(ctx), and pass the modified request to the next handler. Every downstream handler sees the value. The context is the thread that ties the request together.
The slog package gives the middleware its writing surface. A *slog.Logger is configured with a slog.Handler that decides where records go and what format they take. The handler chain is composable; a slog.LogValuer interface lets a value control its own serialization; the WithAttrs and WithGroup methods let the logger accrete context. The package's design choice — handler-as-interface — is what makes the audit-log destination swappable without touching the call site.
These three primitives compose. The middleware chain is the scaffold; the context is the carrier; slog is the surface. The composition is the audit pipeline.
§ IIICode Worked Example
The audit middleware decorates every HTTP handler in the service. On entry it pulls the SPIFFE identity from the TLS handshake (which the workload-identity lesson from Mon 2026-05-20 set up), generates a request ID, attaches both to the context, and logs an entry naming the action. On exit it logs the response status and the latency. Every entry signs with a running hash chain.
package audit
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
)
type ctxKey string
const (
KeyRequestID ctxKey = "audit.requestID"
KeyIdentity ctxKey = "audit.identity"
)
type Chain struct {
mu sync.Mutex
prevHash []byte
logger *slog.Logger
}
func New(logger *slog.Logger, seed []byte) *Chain {
return &Chain{logger: logger, prevHash: seed}
}
func (c *Chain) Middleware(action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := newRequestID()
identity := identityFromTLS(r)
ctx := context.WithValue(r.Context(), KeyRequestID, requestID)
ctx = context.WithValue(ctx, KeyIdentity, identity)
entry := map[string]any{
"ts": start.UTC().Format(time.RFC3339Nano),
"request_id": requestID,
"identity": identity,
"action": action,
"method": r.Method,
"path": r.URL.Path,
}
wrapped := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(wrapped, r.WithContext(ctx))
entry["status"] = wrapped.status
entry["latency_ms"] = time.Since(start).Milliseconds()
entry["prev_hash"] = c.appendAndHash(entry)
c.logger.LogAttrs(ctx, slog.LevelInfo, "audit",
slog.Any("entry", entry))
})
}
}
func (c *Chain) appendAndHash(entry map[string]any) string {
c.mu.Lock()
defer c.mu.Unlock()
payload, _ := json.Marshal(entry)
h := sha256.New()
h.Write(c.prevHash)
h.Write(payload)
sum := h.Sum(nil)
c.prevHash = sum
return hex.EncodeToString(sum)
}
The structure carries three properties. The middleware is a closure over action so the chain author tags each route with a human-readable verb (read-market-data, call-bedrock-endpoint, write-inference-result). The appendAndHash method makes the log tamper-evident — to alter any prior entry, an adversary would have to rewrite every entry after it because each entry's prev_hash cumulates the SHA-256 over the prior payload concatenated with the prior hash. The mutex around the hash mutation gives the chain its monotonicity guarantee even under concurrent request volume.
The slog handler is configured at service startup. A JSON handler writes to a file descriptor; a custom handler can split the output stream — high-cardinality fields to a forensic store, low-cardinality fields to a metrics surface:
package main
import (
"log/slog"
"os"
"your-org/audit"
)
func main() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: false,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})
logger := slog.New(handler)
chain := audit.New(logger, loadSeedFromKMS())
http.Handle("/market-data",
chain.Middleware("read-market-data")(marketDataHandler))
http.Handle("/inference",
chain.Middleware("call-bedrock-endpoint")(inferenceHandler))
http.Handle("/write-result",
chain.Middleware("write-inference-result")(writeResultHandler))
_ = http.ListenAndServeTLS(":8443", "tls.crt", "tls.key", nil)
}
The seed for the hash chain reads from KMS (or HashiCorp Vault, per Tuesday's lesson) — never hardcoded, never written to source control. The chain genesis is the seed; the chain's continuity from genesis to now is what the auditor verifies offline by replaying every entry's hash computation.
A downstream handler reads the request ID and identity off the context:
func marketDataHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID := ctx.Value(audit.KeyRequestID).(string)
identity := ctx.Value(audit.KeyIdentity).(string)
rows, err := fetchMarketRows(ctx, identity)
if err != nil {
slog.ErrorContext(ctx, "fetch failed",
slog.String("request_id", requestID),
slog.String("identity", identity),
slog.String("err", err.Error()))
http.Error(w, "internal", http.StatusInternalServerError)
return
}
writeRowsJSON(w, rows)
}
Every log line emitted by any handler in the chain carries the request ID and identity through the context. The forensic story for a single request reconstructs from the audit log by filtering on request ID; the forensic story for an identity reconstructs by filtering on identity and replaying the hash chain in order.
§ IVConnection to Today's Ops Lesson
The Ops lesson names the audit log as one of three trust primitives and specifies four properties — append-only, identity-keyed, signed at write-time, semantically rich. The Go middleware shown above gives all four. Append-only comes from the hash chain — any rewrite breaks downstream entries. Identity-keyed comes from the TLS-handshake extraction tied to the SPIFFE SVID. Signed at write-time comes from the SHA-256 chaining; the entry's signature is its position in the chain. Semantically rich comes from slog's structured key-value record — the entry carries action, method, path, status, latency, and any handler-attached fields.
What the Ops lesson calls the application emits a structured audit-log entry on every action it takes is, in Go, an http.Handler decorator parameterized by the action name and wrapped around the route. The decoration is uniform; the parameter changes per route; the chain is the same.
A real Bedrock-calling handler would add a model-ARN attribute, a prompt-SHA-256 attribute, and a tool-trace attribute to the slog record before the outer middleware closes the entry. Slog's WithAttrs and LogAttrs are designed for this — the handler can append attributes mid-request without rebuilding the logger.
§ VPrior-Lesson Reach
The Fri 2026-05-22 lesson on Go channels (fan-out / fan-in) is the upstream primitive for a multi-handler service where audit-log records feed a downstream pipeline — a Loki shipper, a hash-chain verifier, an alerting fan-out. The audit handler emits records; a buffered channel decouples emission from shipping; a worker pool (Mon 2026-05-25 lesson on worker pools and semaphores) ships records under bounded concurrency and back-pressure.
The Tue 2026-05-19 lesson on context.Context for distributed tracing is the cousin primitive. Where that lesson carried OpenTelemetry trace IDs and spans through the context, this lesson carries audit identity and request ID. The two coexist; both ride the same context object; both are read at every downstream handler. A production service does both — trace IDs for the SRE, audit IDs for the auditor.
§ VIClosing
A middleware chain is a decorator built from one interface and one signature. A context is a thread that ties handlers together. A structured logger is a key-value sink with a swappable handler. Three small things; one audit pipeline.
The hash chain is the discipline that makes the log evidentiary. Without it, the log is a record; with it, the log is a witness. The seed is the genesis; the chain is the lineage; the offline verification is the audit. The witness signs every line.
Examine well. The chain begins where the seed lives.
Filed 2026-05-28 Fajr ANCHOR #12; first Thursday Praetor-cycle lesson trio under cert-prep extension v3.1