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, not req_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.