Transport Middleware and Logging Specification¶
- Authors
- Matt Cockayne, Claude Opus 4.6 (AI drafting assistant)
- Date
- 26 March 2026
- Status
- IMPLEMENTED
Overview¶
The HTTP and gRPC server packages (pkg/http, pkg/grpc) currently have no middleware infrastructure. Consumers who want to add cross-cutting concerns (logging, recovery, auth, metrics) must manually compose handler wrappers or gRPC interceptor chains โ a pattern that quickly becomes unwieldy as the number of concerns grows.
This spec introduces two things:
- Middleware chaining โ a lightweight, composable mechanism (inspired by justinas/alice) for declaring and applying ordered middleware stacks for both HTTP and gRPC, without pulling in an external dependency.
- Built-in request logging middleware โ the first middleware shipped with the framework, providing per-request structured logging using
logger.Logger.
Motivation¶
The recent shutdown debugging effort revealed that the lack of request-level logging makes it difficult to reason about in-flight connections during graceful shutdown. More broadly, the absence of any middleware infrastructure means every consumer reinvents handler composition. A minimal chaining API solves both problems.
Terminology¶
| Term | Definition |
|---|---|
| HTTP Middleware | A function with signature func(http.Handler) http.Handler โ the standard Go middleware convention. |
| gRPC Interceptor | A grpc.UnaryServerInterceptor or grpc.StreamServerInterceptor โ the standard gRPC middleware convention. |
| Chain | An ordered collection of middleware/interceptors that composes into a single wrapper. |
| Transport | Either HTTP or gRPC โ the two server transports GTB supports. |
Design Decisions¶
- No external dependency: The chaining mechanism is trivial to implement (~30 lines per transport). No need for
justinas/aliceas a dependency โ we adopt its ergonomics, not its code. - Standard signatures: HTTP middleware uses
func(http.Handler) http.Handler. gRPC uses the native interceptor types. No custom abstractions that fight the ecosystem. - Separate chains per transport: HTTP and gRPC have different middleware signatures. Each gets its own
Chaintype rather than a shared abstraction. - Opt-in composition: Chains are built explicitly by the consumer. The
Registerconvenience functions gain an optionalWithMiddleware/WithInterceptorsoption so consumers can declare their stack at registration time. - Logging middleware is built-in but not default: Shipped with the framework as a ready-to-use middleware, but not wired in unless the consumer includes it in their chain.
Public API¶
Middleware Chaining¶
Package pkg/http¶
// Middleware is the standard Go HTTP middleware signature.
type Middleware func(http.Handler) http.Handler
// Chain composes zero or more Middleware into a single Middleware.
// Middleware is applied left-to-right: the first middleware in the list is
// the outermost wrapper (first to see the request, last to see the response).
//
// chain := gtbhttp.Chain(recovery, logging, auth)
// handler := chain.Then(mux)
type Chain struct {
middlewares []Middleware
}
// NewChain creates a new middleware chain from the given middleware functions.
func NewChain(middlewares ...Middleware) Chain
// Append returns a new Chain with additional middleware appended.
// The original chain is not modified.
func (c Chain) Append(middlewares ...Middleware) Chain
// Extend returns a new Chain that applies c's middleware first, then other's.
func (c Chain) Extend(other Chain) Chain
// Then applies the middleware chain to the given handler and returns
// the resulting http.Handler.
//
// If handler is nil, http.DefaultServeMux is used.
func (c Chain) Then(handler http.Handler) http.Handler
// ThenFunc is a convenience for Then(http.HandlerFunc(fn)).
func (c Chain) ThenFunc(fn http.HandlerFunc) http.Handler
Package pkg/grpc¶
// InterceptorChain composes zero or more gRPC interceptors into ordered
// slices suitable for grpc.ChainUnaryInterceptor and grpc.ChainStreamInterceptor.
type InterceptorChain struct {
unary []grpc.UnaryServerInterceptor
stream []grpc.StreamServerInterceptor
}
// NewInterceptorChain creates a new interceptor chain.
// Each Interceptor argument provides a unary interceptor, a stream interceptor,
// or both. Nil entries are silently skipped.
func NewInterceptorChain(interceptors ...Interceptor) InterceptorChain
// Interceptor groups a paired unary and stream interceptor.
// Either field may be nil if the interceptor only applies to one RPC type.
type Interceptor struct {
Unary grpc.UnaryServerInterceptor
Stream grpc.StreamServerInterceptor
}
// Append returns a new InterceptorChain with additional interceptors appended.
func (c InterceptorChain) Append(interceptors ...Interceptor) InterceptorChain
// ServerOptions returns grpc.ServerOption values that install the chain.
// This is the primary integration point โ pass the result to grpc.NewServer
// or to gtbgrpc.NewServer's variadic options.
//
// chain := gtbgrpc.NewInterceptorChain(logging, recovery)
// srv, _ := gtbgrpc.NewServer(cfg, chain.ServerOptions()...)
func (c InterceptorChain) ServerOptions() []grpc.ServerOption
Registration Integration¶
Both Register functions gain optional configuration for middleware:
pkg/http¶
// RegisterOption configures optional behaviour for HTTP server registration.
type RegisterOption func(*registerConfig)
// WithMiddleware sets the middleware chain applied to the handler before
// it is passed to the HTTP server. Health endpoints (/healthz, /livez,
// /readyz) are mounted outside the chain and are never affected by middleware.
func WithMiddleware(chain Chain) RegisterOption
// Register signature gains variadic options:
func Register(ctx context.Context, id string, controller controls.Controllable,
cfg config.Containable, logger logger.Logger, handler http.Handler,
opts ...RegisterOption) (*http.Server, error)
pkg/grpc¶
// RegisterOption configures optional behaviour for gRPC server registration.
type RegisterOption func(*registerConfig)
// WithInterceptors prepends the given interceptor chain before any
// grpc.ServerOption interceptors passed via the variadic opts.
func WithInterceptors(chain InterceptorChain) RegisterOption
// Register signature gains RegisterOption alongside existing grpc.ServerOption:
func Register(ctx context.Context, id string, controller controls.Controllable,
cfg config.Containable, logger logger.Logger,
opts ...any) (*grpc.Server, error)
// opts accepts both grpc.ServerOption and RegisterOption values.
Built-in Logging Middleware¶
Package pkg/http¶
// LoggingMiddleware returns an HTTP Middleware that logs each completed request.
func LoggingMiddleware(logger logger.Logger, opts ...LoggingOption) Middleware
Package pkg/grpc¶
// LoggingInterceptor returns an Interceptor (unary + stream) that logs
// each completed RPC.
func LoggingInterceptor(logger logger.Logger, opts ...LoggingOption) Interceptor
Logging Options¶
Options are defined in each transport package but follow the same naming and semantics.
// LogFormat controls the output format of the logging middleware.
type LogFormat int
const (
// FormatStructured emits structured key-value fields via logger.Logger.
// This is the default format and integrates with whatever formatter the
// logger is configured with (text, JSON, logfmt, etc.).
FormatStructured LogFormat = iota
// FormatCommon emits NCSA Common Log Format (CLF):
// 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /page HTTP/1.1" 200 2326
// Widely supported by log aggregators (ELK, Splunk, Datadog).
FormatCommon
// FormatCombined emits NCSA Combined Log Format (CLF + Referer + User-Agent):
// 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /page HTTP/1.1" 200 2326 "http://ref.example.com" "curl/8.0"
// The de-facto standard for web server access logs.
FormatCombined
// FormatJSON emits a single JSON object per request with all captured fields.
// Useful when the underlying logger is not JSON-formatted but JSON logs are
// required for the observability pipeline.
// {"method":"GET","path":"/page","status":200,"bytes":2326,"latency":"12.3ms",...}
FormatJSON
)
// LoggingOption configures transport logging behaviour.
type LoggingOption func(*loggingConfig)
// WithFormat sets the log output format. Defaults to FormatStructured.
// FormatCommon, FormatCombined, and FormatJSON are HTTP-only formats;
// they are silently ignored by the gRPC logging interceptor which always
// uses FormatStructured.
func WithFormat(format LogFormat) LoggingOption
// WithLogLevel sets the log level for successful requests.
// Defaults to logger.InfoLevel. Errors always log at logger.ErrorLevel.
func WithLogLevel(level logger.Level) LoggingOption
// WithoutLatency disables the "latency" field.
// In FormatCommon/FormatCombined mode this is a no-op (those formats have
// no latency field by specification). In FormatJSON it omits the "latency" key.
func WithoutLatency() LoggingOption
// WithoutUserAgent disables the "user_agent" field (HTTP only).
// In FormatCombined mode the User-Agent position is replaced with "-".
func WithoutUserAgent() LoggingOption
// WithPathFilter excludes requests matching the given paths from logging.
// Useful for suppressing noisy health-check endpoints.
// Applies to all formats.
//
// WithPathFilter("/healthz", "/livez", "/readyz")
func WithPathFilter(paths ...string) LoggingOption
// WithHeaderFields logs the specified request header values as fields.
// Header names are normalised to lowercase. Values are truncated to 256 bytes.
// In FormatCommon/FormatCombined mode, extra headers are appended after the
// standard fields. In FormatJSON they appear as additional JSON keys.
//
// WithHeaderFields("x-request-id", "x-forwarded-for")
func WithHeaderFields(headers ...string) LoggingOption
Log Fields and Formats¶
HTTP โ Structured (default)¶
Each request produces a single structured log call via logger.Logger with key-value fields:
| Field | Type | Example | Description |
|---|---|---|---|
method |
string | GET |
HTTP method |
path |
string | /api/health |
Request path (without query string) |
status |
int | 200 |
Response status code |
latency |
duration | 12.3ms |
Time from handler entry to response write |
bytes |
int | 1024 |
Response body size in bytes |
client_ip |
string | 10.0.0.1 |
Client IP from RemoteAddr or X-Forwarded-For |
user_agent |
string | curl/8.0 |
User-Agent header value |
request_id |
string | abc-123 |
From header if WithHeaderFields configured |
HTTP โ Common Log Format (FormatCommon)¶
Follows the NCSA Common Log Format:
Example: 10.0.0.1 - - [26/Mar/2026:14:22:01 +0000] "GET /api/data HTTP/1.1" 200 1024
The ident and auth fields are always - (not applicable in this context). Timestamp uses CLF format (02/Jan/2006:15:04:05 -0700). Output is written via logger.Info(line) as a single string argument.
HTTP โ Combined Log Format (FormatCombined)¶
Extends Common with Referer and User-Agent:
Example: 10.0.0.1 - - [26/Mar/2026:14:22:01 +0000] "GET /api/data HTTP/1.1" 200 1024 "https://example.com" "curl/8.0"
If WithoutUserAgent() is set, the User-Agent position is replaced with "-".
HTTP โ JSON (FormatJSON)¶
Emits a single JSON object per request containing all captured fields. Written via logger.Info(jsonString). Useful when the logger itself is not JSON-formatted but a JSON access log is required for the observability pipeline.
{"timestamp":"2026-03-26T14:22:01.123Z","method":"GET","path":"/api/data","status":200,"bytes":1024,"latency":"12.3ms","client_ip":"10.0.0.1","user_agent":"curl/8.0"}
Fields respect the same options as structured mode (WithoutLatency, WithoutUserAgent, WithHeaderFields).
gRPC¶
| Field | Type | Example | Description |
|---|---|---|---|
method |
string | /pkg.Service/DoThing |
Full gRPC method name |
code |
string | OK |
gRPC status code name |
latency |
duration | 5.1ms |
Time from handler entry to response |
type |
string | unary / stream |
RPC type |
peer |
string | 10.0.0.1:54321 |
Peer address from transport credentials |
Usage Examples¶
HTTP โ composing a middleware stack¶
mux := http.NewServeMux()
mux.HandleFunc("/api/data", dataHandler)
// Build a middleware chain
chain := gtbhttp.NewChain(
gtbhttp.RecoveryMiddleware(l), // outermost โ catches panics from everything below
gtbhttp.LoggingMiddleware(l,
gtbhttp.WithPathFilter("/healthz", "/livez", "/readyz"),
gtbhttp.WithHeaderFields("x-request-id"),
),
authMiddleware, // application-specific
)
// Option A: apply manually
srv, _ := gtbhttp.NewServer(ctx, cfg, chain.Then(mux))
// Option B: apply via Register
_, _ = gtbhttp.Register(ctx, "http", controller, cfg, l, mux,
gtbhttp.WithMiddleware(chain),
)
HTTP โ log format selection¶
// Combined Log Format โ classic Apache-style access logs
chain := gtbhttp.NewChain(
gtbhttp.LoggingMiddleware(l,
gtbhttp.WithFormat(gtbhttp.FormatCombined),
gtbhttp.WithPathFilter("/healthz", "/livez", "/readyz"),
),
)
// JSON access logs for structured observability pipelines
chain := gtbhttp.NewChain(
gtbhttp.LoggingMiddleware(l,
gtbhttp.WithFormat(gtbhttp.FormatJSON),
gtbhttp.WithHeaderFields("x-request-id"),
),
)
HTTP โ extending chains¶
// Base chain shared across all services
base := gtbhttp.NewChain(
gtbhttp.RecoveryMiddleware(l),
gtbhttp.LoggingMiddleware(l),
)
// Admin routes get additional auth
admin := base.Append(adminAuthMiddleware)
adminHandler := admin.Then(adminMux)
publicHandler := base.Then(publicMux)
gRPC โ composing interceptors¶
chain := gtbgrpc.NewInterceptorChain(
gtbgrpc.LoggingInterceptor(l,
gtbgrpc.WithPathFilter("/grpc.health.v1.Health/Check"),
),
gtbgrpc.Interceptor{Unary: authUnaryInterceptor}, // unary-only
)
// Option A: apply via ServerOptions
srv, _ := gtbgrpc.NewServer(cfg, chain.ServerOptions()...)
// Option B: apply via Register
srv, _ := gtbgrpc.Register(ctx, "grpc", controller, cfg, l,
gtbgrpc.WithInterceptors(chain),
)
Internal Implementation¶
HTTP Chain¶
The Chain type is a simple slice of Middleware. Then applies them in reverse order so the first middleware in the list is the outermost wrapper:
func (c Chain) Then(h http.Handler) http.Handler {
for i := len(c.middlewares) - 1; i >= 0; i-- {
h = c.middlewares[i](h)
}
return h
}
Append and Extend return new slices โ chains are immutable after creation.
gRPC InterceptorChain¶
Maintains two parallel slices (unary and stream). ServerOptions returns:
func (c InterceptorChain) ServerOptions() []grpc.ServerOption {
var opts []grpc.ServerOption
if len(c.unary) > 0 {
opts = append(opts, grpc.ChainUnaryInterceptor(c.unary...))
}
if len(c.stream) > 0 {
opts = append(opts, grpc.ChainStreamInterceptor(c.stream...))
}
return opts
}
HTTP loggingConfig¶
type loggingConfig struct {
format LogFormat
level logger.Level
logLatency bool
logUserAgent bool
pathFilter map[string]struct{}
headerFields []string
}
Defaults: format: FormatStructured, level: InfoLevel, logLatency: true, logUserAgent: true.
The middleware wraps http.ResponseWriter with a thin interceptor that captures statusCode and bytesWritten via WriteHeader and Write overrides. After the inner handler returns, the configured format's emitter is called:
- FormatStructured:
logger.With(keyvals...).Info("request completed") - FormatCommon / FormatCombined:
logger.Info(formattedLine)โ single pre-formatted string - FormatJSON:
logger.Info(jsonString)โ single JSON-encoded string
Response Writer Wrapper (HTTP)¶
Must implement http.Flusher and http.Hijacker if the underlying writer supports them, to avoid breaking WebSocket upgrades or SSE.
gRPC loggingConfig¶
Shares the same shape as the HTTP config. The unary interceptor wraps the handler call; the stream interceptor wraps grpc.ServerStream to capture completion. Both extract the method name from info.FullMethod and the peer address from peer.FromContext.
Project Structure¶
pkg/http/
chain.go # Chain type + NewChain, Append, Extend, Then
chain_test.go
logging.go # LoggingMiddleware + options + responseLogger
logging_test.go
options.go # RegisterOption, WithMiddleware
options_test.go
pkg/grpc/
chain.go # InterceptorChain + NewInterceptorChain, Append, ServerOptions
chain_test.go
logging.go # LoggingInterceptor + options
logging_test.go
options.go # RegisterOption, WithInterceptors
options_test.go
No new packages. Middleware infrastructure lives alongside the server code it wraps.
Generator Impact¶
None. The generator scaffolds server registration but does not prescribe middleware. Consumers add middleware explicitly.
Error Handling¶
- Chain types do not produce errors. A nil middleware or nil interceptor is silently skipped.
- The logging middleware itself does not produce errors. If the underlying handler panics, the panic propagates as normal (recovery is the responsibility of a separate recovery middleware).
- Failed requests (5xx) are logged at
logger.ErrorLevelregardless of the configured level. 4xx requests use the configured level (defaultInfo).
Testing Strategy¶
Unit Tests¶
- Chain (HTTP): Verify ordering โ first middleware is outermost. Verify
Appendreturns a new chain (immutability). VerifyThen(nil)usesDefaultServeMux. VerifyThenFuncconvenience. - Chain (gRPC): Verify
ServerOptionsproduces correctChainUnaryInterceptor/ChainStreamInterceptoroptions. Verify nil interceptors are skipped. - Logging (HTTP): Use
httptest.NewRecorderwith a known handler. Assert log output contains expected fields (method, path, status, latency). Verify path filtering suppresses output. Verify header field extraction. - Logging (gRPC): Use
bufconnwith a test service. Assert log output for unary and streaming RPCs. Verify method filtering and peer extraction. - Options: Each option has a dedicated test verifying its effect on log output.
- Register integration: Verify
WithMiddleware/WithInterceptorsapply the chain correctly and that health endpoints remain unaffected.
Integration Tests¶
- Wire middleware through
Registerinto a full controller lifecycle. Verify logs appear during normal operation and during graceful shutdown with in-flight requests.
Coverage Target¶
90% for all new files.
Migration & Compatibility¶
- No breaking changes. All additions are additive and opt-in.
- Existing
Registerfunction signatures gain variadic options but remain backwards-compatible โ zero options produces identical behaviour to today. - Existing consumers who manually wrap handlers continue to work unchanged.
- The
ChainandInterceptorChaintypes are transport-specific to allow future transport-specific extensions without coupling.
Future Considerations¶
- Recovery middleware: A
RecoveryMiddleware(logger)that catches panics and converts them to 500/INTERNAL errors. Likely the next built-in middleware after logging. - Request ID middleware: Generates
X-Request-Idif not present, which the logging middleware picks up viaWithHeaderFields. - Metrics extraction: The same
responseLoggerwrapper could feed latency histograms to a metrics middleware. Keep interfaces clean so they can compose. - Sampling: A
WithSampler(rate float64)logging option could be added later without changing the core interface. - Body logging: Intentionally excluded for v1 (security and performance). Could be added behind a
WithBodyLogging(maxBytes int)option for debug use. - Conditional chains: A
Chain.If(condition bool, middleware...)method for conditionally including middleware based on config flags.
Implementation Phases¶
Phase 1: HTTP Middleware Chaining¶
- Implement
Chaintype withNewChain,Append,Extend,Then,ThenFunc. - Implement
RegisterOptionandWithMiddlewareforRegister. - Unit tests for chain ordering, immutability, and nil handling.
Phase 2: gRPC Interceptor Chaining¶
- Implement
InterceptorChaintype withNewInterceptorChain,Append,ServerOptions. - Implement
Interceptortype andRegisterOption/WithInterceptors. - Unit tests for interceptor chain composition and
ServerOptionsoutput.
Phase 3: HTTP Logging Middleware¶
- Implement
responseLoggerwrapper with status/bytes capture. - Implement
LoggingMiddlewarewith default fields. - Implement options:
WithLogLevel,WithoutLatency,WithPathFilter. - Unit tests with
httptest.
Phase 4: gRPC Logging Interceptor¶
- Implement unary interceptor with method/code/latency fields.
- Implement stream interceptor wrapping
grpc.ServerStream. - Implement options:
WithLogLevel,WithoutLatency,WithPathFilter. - Unit tests with
bufconn.
Phase 5: Extended Options and Integration¶
WithHeaderFields(HTTP) andWithoutUserAgent(HTTP).- Peer address extraction (gRPC).
- Client IP extraction with
X-Forwarded-Forsupport (HTTP). - Integration tests wired through the controller lifecycle.
Open Questions¶
- Register API for gRPC: The current
Registeraccepts...grpc.ServerOption. AddingRegisterOptionalongside requires either a mixed variadic (...anywith type-switching) or a separate options parameter. The spec proposes...anyfor simplicity โ is this acceptable, or should we use a separateRegisterWithOptionsfunction to keep type safety? - Should 4xx responses log at
Warnlevel by default? Currently proposed asInfo. Some teams preferWarnfor client errors to surface them more visibly. - gRPC metadata logging: Should there be a
WithMetadataFieldsoption analogous toWithHeaderFields, or is this too niche for v1? - Health endpoint exclusion: The spec proposes that
WithMiddlewareinRegistermounts health endpoints outside the chain. Should consumers be able to opt health endpoints into the middleware chain (e.g. for access logging on health checks)?