Contextual Logging with ctxzap
If you’ve written Go services, you know the drill. You need request IDs, user IDs, and other metadata in your logs. But passing them as parameters everywhere? That gets messy fast. That’s why I built ctxzap - it leverages Go’s context.Context
to solve this cleanly.
The Problem
Let me show you what we’re dealing with:
type impl struct {
logger *zap.Logger
}
func (i *impl) processOrder(userID string, requestID string, orderID string) error {
i.logger.Info("Processing order",
zap.String("user_id", userID),
zap.String("request_id", requestID),
zap.String("order_id", orderID))
// Now we need to pass all these parameters down...
if err := validateOrder(userID, requestID, orderID); err != nil {
return err
}
if err := checkInventory(requestID, orderID); err != nil {
return err
}
return processPayment(userID, requestID, orderID)
}
func (i *impl) validateOrder(userID string, requestID string, orderID string) error {
i.logger.Info("Validating order",
zap.String("user_id", userID),
zap.String("request_id", requestID),
zap.String("order_id", orderID))
// ... validation logic
}
You see the issues:
- Function signatures that keep growing
- Parameters being passed just for logging
- Adding a field? Update every function in the chain
- Business logic drowning in ceremony
The Solution: ctxzap
ctxzap attaches structured logging fields directly to the context. Let me walk you through it:
Setting Up
If you’re using Uber FX (and you should consider it), ctxzap drops right in:
// In your main.go or module setup
fx.Options(
// Provide your base logger
fx.Provide(func() *zap.Logger {
logger, _ := zap.NewProduction()
return logger
}),
// Wrap it with ctxzap
fx.Provide(func(zapLogger *zap.Logger) *ctxzap.Logger {
return ctxzap.New(zapLogger)
}),
// Your services can now inject *ctxzap.Logger
fx.Provide(NewOrderService),
)
Even better - there’s a ready-made FX module:
import "github.com/algobardo/ctxzap/ctxzapfx"
fx.Options(
// Provide your base logger
fx.Provide(func() *zap.Logger {
logger, _ := zap.NewProduction()
return logger
}),
// Use the ctxzapfx module
ctxzapfx.Module,
// Your services can now inject *ctxzap.Logger
fx.Provide(NewOrderService),
)
Adding Context at Entry Points
The principle is simple: enrich the context when you have the data, then just pass context around.
In a gRPC interceptor, for instance:
func loggingInterceptor(logger *ctxzap.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Extract metadata
md, _ := metadata.FromIncomingContext(ctx)
// Add request metadata to context
if requestID := md.Get("request-id"); len(requestID) > 0 {
ctx = ctxzap.WithFields(ctx, zap.String("request_id", requestID[0]))
}
if userID := md.Get("user-id"); len(userID) > 0 {
ctx = ctxzap.WithFields(ctx, zap.String("user_id", userID[0]))
}
// Call your handler - context now has the fields
return handler(ctx, req)
}
}
Same pattern works for HTTP middleware, message queues, anywhere you have an entry point. Add once, use everywhere.
Clean Code in Practice
Back to our order processing. With ctxzap, it becomes:
type OrderService struct {
logger *ctxzap.Logger
}
func (s *OrderService) ProcessOrder(ctx context.Context, order Order) error {
// Add order-specific context
ctx = ctxzap.WithFields(ctx, zap.String("order_id", order.ID))
// All these fields (request_id, user_id, order_id) are automatically included!
s.logger.Info(ctx, "Processing order")
// Just pass ctx - no extra parameters
if err := s.validateOrder(ctx, order); err != nil {
return err
}
if err := s.checkInventory(ctx, order); err != nil {
return err
}
return s.processPayment(ctx, order)
}
func (s *OrderService) validateOrder(ctx context.Context, order Order) error {
// request_id, user_id, order_id all automatically included
s.logger.Info(ctx, "Validating order")
// ... validation logic
}
Clean function signatures. No parameter sprawl. The context carries what you need.
A Note on Log Levels
You might think “with tracing, I’ll log everything and correlate by trace ID.” Two problems with that.
First, you generate mountains of logs. Most requests are boring - the interesting stuff (cache behavior, payment processing) happens deep in specific paths. All those intermediate logs? They’re costing you noise and money.
Second, trace correlation isn’t free. You need complex logging platforms make it work smoothly. Otherwise you’re just grepping through files.
I log where work happens, with all the context attached at the log emission point:
func processOrder(ctx context.Context, order Order) error {
// Don't log here - nothing interesting happens
return processPayment(ctx, order)
}
func processPayment(ctx context.Context, order Order) error {
// Log here where real work happens
logger.Info(ctx, "Payment processed successfully",
zap.Float64("amount", order.Total))
// ... payment operation
}
Quick Tips
- Add context at boundaries - HTTP handlers, gRPC interceptors, queue consumers
- Pick field names and stick to them -
request_id
everywhere, notreq_id
in some places - Layer your context - General fields up top, specific ones as you go deeper
What You Get
In production, ctxzap means:
- Clean function signatures
- Every log has full context automatically
- Debugging is straightforward - the context is always there
- Your business logic stays focused
Wrapping Up
ctxzap is about pragmatic logging. Add context once, pass it naturally through your code, get useful logs without the mess. Simple enough to actually use, powerful enough to make debugging easier.
Give it a try and let me know what you think.