⚡ Zax

August 31, 2026 · View on GitHub

⚡ Zax

Context-Aware Logging for Go with Uber's Zap

Go Version Go Reference codecov Go Report Card License: AGPL v3 GitHub release

CodeQL Check & Build


Zax seamlessly integrates Zap Logger with Go's context.Context, enabling you to carry structured logging fields across your entire request lifecycle without boilerplate.

📚 Table of Contents


🎯 Why Zax?

In modern Go applications, especially microservices, you often need to:

  • 🔍 Trace requests across multiple functions and services
  • 📊 Correlate logs with trace IDs, span IDs, and user context
  • 🧹 Avoid boilerplate by not passing loggers as function parameters
  • Maintain performance without sacrificing structured logging

Zax solves these problems elegantly by storing Zap fields in context, making them available wherever you need to log.

✨ Features

FeatureDescription
🚀 Zero DependenciesOnly requires go.uber.org/zap
🎯 Context-NativeWorks seamlessly with Go's context.Context
High PerformanceMinimal, predictable overhead (see Benchmarks)
🔧 Simple APIJust 7 functions to learn
🍬 SugaredLogger SupportWorks with both *zap.Logger and *zap.SugaredLogger
🧪 Well TestedComprehensive test coverage

📦 Installation

go get -u github.com/yuseferi/zax/v2

Requirements: Go 1.26.1 or higher

🛠 Tasks

This project uses Task to keep local commands and CI in sync.

Install Task:

go run github.com/go-task/task/v3/cmd/task@latest --version

Common commands:

task build
task test
task test:race
task lint
task bench
task ci

task ci runs the same lint and test flow used by GitHub Actions.

🚀 Releases

This project uses semantic-release to automate Git tags and GitHub releases.

Release automation is configured in .releaserc.json and runs from .github/workflows/release.yml after the Quality check workflow succeeds on master.

Use Conventional Commits so semantic-release can determine the next version:

  • fix: for patch releases
  • feat: for minor releases
  • feat!: or a BREAKING CHANGE: footer for major releases

You can preview the next release locally with:

task release:dry-run

🚀 Quick Start

package main

import (
    "context"
    
    "github.com/yuseferi/zax/v2"
    "go.uber.org/zap"
)

func main() {
    logger, _ := zap.NewProduction()
    defer logger.Sync()
    
    ctx := context.Background()
    
    // Add trace_id to context
    ctx = zax.Set(ctx, []zap.Field{
        zap.String("trace_id", "abc-123"),
        zap.String("user_id", "user-456"),
    })
    
    // Log with context fields - automatically includes trace_id and user_id
    logger.With(zax.Get(ctx)...).Info("request started")
    
    // Pass context to other functions
    processRequest(ctx, logger)
}

func processRequest(ctx context.Context, logger *zap.Logger) {
    // All logs automatically include trace_id and user_id!
    logger.With(zax.Get(ctx)...).Info("processing request")
    
    // Append additional fields without losing existing ones
    ctx = zax.Append(ctx, []zap.Field{
        zap.String("step", "validation"),
    })
    
    logger.With(zax.Get(ctx)...).Info("validation complete")
}

Output:

{"level":"info","msg":"request started","trace_id":"abc-123","user_id":"user-456"}
{"level":"info","msg":"processing request","trace_id":"abc-123","user_id":"user-456"}
{"level":"info","msg":"validation complete","trace_id":"abc-123","user_id":"user-456","step":"validation"}

📖 API Reference

Core Functions

Set(ctx, fields) context.Context

Stores zap fields in context. Replaces any existing fields.

ctx = zax.Set(ctx, []zap.Field{
    zap.String("trace_id", "my-trace-id"),
    zap.Int("request_num", 42),
})

Append(ctx, fields) context.Context

Appends fields to existing context fields. Preserves previously set fields.

// Existing: trace_id
ctx = zax.Append(ctx, []zap.Field{
    zap.String("span_id", "my-span-id"),
})
// Now has: trace_id + span_id

When the same key is added multiple times, later fields follow Zap's normal behavior and take precedence at log time.

Get(ctx) []zap.Field

Retrieves all stored fields from context.

fields := zax.Get(ctx)
logger.With(fields...).Info("message")

GetField(ctx, key) zap.Field

Retrieves a specific field by key. Returns the zero-value zap.Field when the key is not present.

traceField := zax.GetField(ctx, "trace_id")
fmt.Println(traceField.String) // "my-trace-id"

LookupField(ctx, key) (zap.Field, bool)

Like GetField, but also reports whether the key was found — useful when a stored field may legitimately hold a zero value.

if field, ok := zax.LookupField(ctx, "attempt"); ok {
    fmt.Println(field.Integer)
}

Remove(ctx, keys...) context.Context

Returns a context with the given keys removed from the stored fields. Handy for scrubbing sensitive data (e.g. PII) before passing a context on.

ctx = zax.Remove(ctx, "user_email", "api_token")

GetSugared(ctx) []any

Returns fields as key-value pairs for SugaredLogger.

sugar := logger.Sugar()
sugar.With(zax.GetSugared(ctx)...).Info("sugared log")

GetSugared converts fields through Zap's encoder so common field types like strings, bools, numbers, errors, durations, and times are preserved.

🔥 Real-World Example

HTTP Middleware with Distributed Tracing

package main

import (
    "context"
    "net/http"
    
    "github.com/yuseferi/zax/v2"
    "go.uber.org/zap"
)

type Server struct {
    logger *zap.Logger
}

// Middleware injects trace context into all requests
func (s *Server) TracingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // Extract or generate trace ID
        traceID := r.Header.Get("X-Trace-ID")
        if traceID == "" {
            traceID = generateTraceID()
        }
        
        // Store in context
        ctx = zax.Set(ctx, []zap.Field{
            zap.String("trace_id", traceID),
            zap.String("method", r.Method),
            zap.String("path", r.URL.Path),
        })
        
        s.logger.With(zax.Get(ctx)...).Info("request received")
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Handler automatically has access to trace context
func (s *Server) HandleUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Add handler-specific context
    ctx = zax.Append(ctx, []zap.Field{
        zap.String("handler", "user"),
    })
    
    user, err := s.fetchUser(ctx)
    if err != nil {
        s.logger.With(zax.Get(ctx)...).Error("failed to fetch user", zap.Error(err))
        http.Error(w, "Internal Error", 500)
        return
    }
    
    s.logger.With(zax.Get(ctx)...).Info("user fetched successfully",
        zap.String("user_id", user.ID),
    )
}

func (s *Server) fetchUser(ctx context.Context) (*User, error) {
    // All logs here include trace_id, method, path, and handler!
    s.logger.With(zax.Get(ctx)...).Debug("querying database")
    // ... database logic
    return &User{}, nil
}

📊 Benchmarks

Zax V2 is optimized for performance. Here's how it compares:

Run benchmarks yourself with:

task bench
# or
go test -bench . -run '^$' -benchmem ./...
Benchmarkns/opB/opallocs/op
Pure Zap~431281
Zax V2~2265845

💡 The extra allocations come from defensively cloning fields on Set/Append/Get and from storing them in the context, so callers can never mutate fields after they are stored. If you need the absolute minimum overhead, pass zap fields directly.

📋 Full Benchmark Results

Measured on an Apple M2 Pro with Go 1.26 and zap v1.28.0:

pkg: github.com/yuseferi/zax/v2
BenchmarkLoggingWithOnlyZap-10    84292609    43.15 ns/op    128 B/op    1 allocs/op
BenchmarkLoggingWithZax-10        15688470   226.2 ns/op    584 B/op    5 allocs/op
PASS

🤝 Contributing

We ❤️ contributions! Here's how you can help:

  1. 🍴 Fork the repository
  2. 🌿 Create a feature branch (git checkout -b feature/amazing-feature)
  3. 💻 Commit your changes (git commit -m 'Add amazing feature')
  4. 📤 Push to the branch (git push origin feature/amazing-feature)
  5. 🎉 Open a Pull Request

Development

# Clone the repository
git clone https://github.com/yuseferi/zax.git
cd zax

# Run tests
go test -v ./...

# Run benchmarks
go test -bench=. -benchmem

# Run linter
golangci-lint run

📄 License

This project is licensed under the GNU Affero General Public License v3.0 - see the LICENSE file for details.


Made with ❤️ by Yusef Mohamadi and contributors

Star this repo if you find it useful!

Report BugRequest Feature