xlog is a Go logging toolkit for context-aware structured logging. It stores loggers in context, falls back to a global logger, supports zap and slog adapters, and includes OpenTelemetry tracing helpers.
- Context-aware logging - logger is extracted from context
- Global logger fallback support with thread-safe replacement
- Structured logging via
fieldpackage - clear separation between logging and field constructors - Backend-agnostic field types - works with Zap, slog, or custom loggers
- Printf-style formatting (Debugf, Infof, etc.)
- All logging levels: Debug, Info, Warn, Error, Fatal, Panic
- Zero allocation when logger is not in context (uses noop adapter)
- OpenTelemetry integration - span management with automatic logger enrichment
- Distributed tracing support with span creation, events, and attributes
go get github.com/ruko1202/xlogFor a complete working example, see example/app.
package main
import (
"context"
"github.com/ruko1202/xlog"
"github.com/ruko1202/xlog/xfield"
"go.uber.org/zap"
)
const (
appName = "xlogApp"
)
func main() {
// Create logger and wrap it in adapter
logger, _ := zap.NewProduction()
defer logger.Sync()
// Add logger to context
ctx := context.Background()
ctx = xlog.ContextWithLogger(ctx, xlog.NewZapAdapter(logger))
// Use logging
xlog.Infof(ctx, "application `%s` started", appName)
xlog.Debug(ctx, "debug information", field.String("version", "1.0.0"))
}It is convenient if you have background jobs that carry their own context.Context
package main
import (
"context"
"github.com/ruko1202/xlog"
"go.uber.org/zap"
)
func main() {
// Replace global logger using zap's native function
logger, _ := zap.NewDevelopment()
zap.ReplaceGlobals(logger)
// Use without adding logger to context
ctx := context.Background()
xlog.Info(ctx, "using global logger")
}Adds a logger to the context.
ctx := xlog.ContextWithLogger(context.Background(), logger)Extracts logger from context. If logger is not found, returns the global logger.
logger := xlog.LoggerFromContext(ctx)
logger.Info("direct zap usage")Use zap's native ReplaceGlobals() function to replace the global logger. This is useful for testing or temporarily changing the logger.
logger, _ := zap.NewProduction()
undo := zap.ReplaceGlobals(logger)
defer undo() // Restore previous logger when doneCreates a new context with a named logger for a specific operation.
ctx = xlog.WithOperation(ctx, "payment-processing",
zap.String("user_id", "12345"),
zap.String("payment_id", "pay_xyz"),
)
xlog.Info(ctx, "processing payment")Recommended alternative - use WithFields instead:
// Instead of WithOperation - use WithFields for better performance
ctx = xlog.WithFields(ctx,
zap.String("operation", "payment-processing"),
zap.String("user_id", "12345"),
zap.String("payment_id", "pay_xyz"),
)
xlog.Info(ctx, "processing payment")Or use fields directly in log calls for single statements:
xlog.Info(ctx, "processing payment",
zap.String("operation", "payment-processing"),
zap.String("user_id", "12345"),
zap.String("payment_id", "pay_xyz"),
)Use WithOperation only when:
- You need the operation name in the logger namespace (affects log structure)
- Performance is not critical
- The operation context will be used for many log statements
Creates a new context with additional fields added to the logger. This is the recommended way to add persistent fields to a logger context.
// Add user context to logger
ctx = xlog.WithFields(ctx,
zap.String("user_id", "12345"),
zap.String("session_id", "sess_xyz"),
)
// All subsequent logs will include these fields
xlog.Info(ctx, "user action performed")
xlog.Debug(ctx, "processing request")Performance: Better than WithOperation but still creates new logger instances. For single log statements, passing fields directly is most efficient.
xlog provides integration with OpenTelemetry for distributed tracing. These functions help manage spans alongside logging.
Sets the global tracer name for creating spans and returns a function to restore the previous name. Call this once during application initialization. This function is thread-safe.
restore := xlog.ReplaceTracerName("my-service")
defer restore() // Restore previous tracer name when doneAdds a tracer to the context. If tracer is nil, the global tracer is used.
tracer := otel.GetTracerProvider().Tracer("my-service")
ctx = xlog.ContextWithTracer(ctx, tracer)Extracts tracer from context. If no tracer is found, returns the global tracer.
tracer := xlog.TracerFromContext(ctx)
ctx, span := tracer.Start(ctx, "my-operation")
defer span.End()Extracts the current span from context. If no span is found, returns a NoopSpan (safe to use).
span := xlog.SpanFromContext(ctx)
span.SetAttributes(attribute.String("key", "value"))WithOperationSpan(ctx context.Context, operation string, fields ...zap.Field) (context.Context, trace.Span)
Creates a new span for an operation and adds it to the context along with an enriched logger. The logger automatically includes the operation name and any additional fields.
ctx, span := xlog.WithOperationSpan(ctx, "process-payment",
zap.String("user_id", "12345"),
zap.String("payment_id", "pay_xyz"),
)
defer span.End()
xlog.Info(ctx, "processing payment") // Includes operation and fieldsReturns:
- New context with span and enriched logger
- Span instance that should be ended with
defer span.End()
Usage pattern:
func handleRequest(ctx context.Context) error {
ctx, span := xlog.WithOperationSpan(ctx, "handleRequest")
defer span.End()
// All logs in this context will include the operation name
xlog.Info(ctx, "request started")
// ...
return nil
}Adds an event to the current span (if present in context). Useful for marking important moments in trace execution.
xlog.AddSpanEvent(ctx, "database query started")
// ... perform database query ...
xlog.AddSpanEvent(ctx, "database query completed")Sets attributes on the current span (if present in context). Use OpenTelemetry attribute types.
import "go.opentelemetry.io/otel/attribute"
xlog.SetSpanAttributes(ctx,
attribute.String("user.id", "12345"),
attribute.Int("user.age", 25),
attribute.Bool("user.premium", true),
)Records an error on the current span and sets its status to Error. Safe to call even when no span is present in context.
if err := doSomething(); err != nil {
xlog.RecordSpanError(ctx, err, trace.WithStackTrace(true))
return err
}Note: All span functions work safely even when no span is present in context (no-op behavior).
All logging functions require context.Context as the first argument.
Functions with structured fields (zap.Field):
Debug(ctx context.Context, msg string, fields ...zap.Field)Info(ctx context.Context, msg string, fields ...zap.Field)Warn(ctx context.Context, msg string, fields ...zap.Field)Error(ctx context.Context, msg string, fields ...zap.Field)Fatal(ctx context.Context, msg string, fields ...zap.Field)Panic(ctx context.Context, msg string, fields ...zap.Field)
xlog.Info(ctx, "user logged in",
zap.String("user_id", "12345"),
zap.String("ip", "192.168.1.1"),
zap.Duration("login_time", time.Second*2),
)Functions with string formatting:
Debugf(ctx context.Context, template string, args ...any)Infof(ctx context.Context, template string, args ...any)Warnf(ctx context.Context, template string, args ...any)Errorf(ctx context.Context, template string, args ...any)Fatalf(ctx context.Context, template string, args ...any)Panicf(ctx context.Context, template string, args ...any)
userID := "12345"
xlog.Infof(ctx, "user %s logged in", userID)
xlog.Errorf(ctx, "request processing error: code %d", 500)The example/app directory contains a complete working application demonstrating xlog integration with OpenTelemetry, distributed tracing, and metrics:
Structure:
main.go- Application setup, Echo server, OpenTelemetry initializationhttp_handler.go- HTTP handlers with span creation and loggingotel.go- OpenTelemetry configuration (traces via gRPC, metrics via Prometheus)worker.go- Background worker simulating HTTP requestscompose.yml- Docker Compose setup with Jaeger, Prometheus, and Grafanaotel-collector-config.yaml- OpenTelemetry Collector configuration
Features demonstrated:
- Context-aware logging with automatic trace ID injection
- Integration with Echo web framework
- OpenTelemetry spans with
xlog.WithOperationSpan() - Distributed tracing with Jaeger
- Prometheus metrics collection
- Trace ID propagation in HTTP headers (X-Request-ID)
- Background workers with proper context handling
Running the example:
cd example
# Start infrastructure (Jaeger, Prometheus, Grafana, OTel Collector)
docker compose up -d
# Run the application
go run app/*.go
# Test the API
curl http://localhost:8080/api/work?user_id=123
curl http://localhost:8080/api/work?user_id=456&fail=true
# View traces: http://localhost:16686 (Jaeger UI)
# View metrics: http://localhost:9090 (Prometheus)
# View dashboards: http://localhost:3000 (Grafana, admin/admin)Recommended approach using WithFields:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Add request context using WithFields (performance-friendly)
ctx = xlog.WithFields(ctx,
zap.String("request_id", uuid.NewString()),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("query", r.URL.RawQuery),
)
xlog.Info(ctx, "request processing started")
if err := processRequest(ctx, r); err != nil {
xlog.Error(ctx, "request processing error", zap.Error(err))
http.Error(w, "Internal Server Error", 500)
return
}
xlog.Info(ctx, "request successfully processed")
}
func processRequest(ctx context.Context, r *http.Request) error {
// For single log - pass fields directly (most efficient)
userID := r.URL.Query().Get("userId")
xlog.Debug(ctx, "processing user request",
zap.String("operation", "process-request"),
zap.String("user_id", userID),
)
// ... business logic ...
return nil
}Alternative (with operation namespace):
If you need the operation name in logger namespace (which affects log structure):
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = xlog.WithOperation(ctx, "handleRequest",
zap.String("request_id", uuid.NewString()),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
)
xlog.Info(ctx, "request processing started")
// ...
}With distributed tracing:
For complete observability with OpenTelemetry spans:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, span := xlog.WithOperationSpan(ctx, "handleRequest",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
)
defer span.End()
xlog.Info(ctx, "request processing started")
// Logs will include trace_id and span_id automatically
// ...
}See example/app for a complete working example with Echo framework and OpenTelemetry.
func getUserByID(ctx context.Context, userID string) (*User, error) {
xlog.Debug(ctx, "fetching user from database", zap.String("user_id", userID))
user, err := db.Query(ctx, "SELECT * FROM users WHERE id = ?", userID)
if err != nil {
xlog.Error(ctx, "database query error",
zap.String("user_id", userID),
zap.Error(err),
)
return nil, err
}
xlog.Info(ctx, "user found", zap.String("user_id", userID))
return user, nil
}func backgroundWorker(ctx context.Context) {
// Add worker context using WithFields
ctx = xlog.WithFields(ctx,
zap.String("worker", "background-processor"),
)
xlog.Info(ctx, "starting background processor")
for {
select {
case <-ctx.Done():
xlog.Info(ctx, "stopping processor")
return
case task := <-taskQueue:
// Performance-friendly: pass fields directly for one-off logs
xlog.Debug(ctx, "processing task",
zap.String("task_id", task.ID),
)
if err := processTask(ctx, task); err != nil {
xlog.Error(ctx, "task processing error",
zap.String("task_id", task.ID),
zap.Error(err),
)
}
}
}
}- Debug - Detailed debug information
- Info - Informational messages about normal operation
- Warn - Warnings about potential issues
- Error - Errors that need to be handled
- Fatal - Critical errors that terminate the application (calls os.Exit(1))
- Panic - Critical errors that cause panic
The package includes a comprehensive test suite. To run tests:
# Run all tests
make tloc
# Run tests with coverage
make test-covOr using go commands directly:
# Run tests
go test -v ./...
# Run tests with coverage
go test -cover ./...# Install linter
make bin-deps
# Run linter
make lint
# Format code
make fmtThe project uses GitHub Actions for continuous integration:
-
CI Pipeline - Runs on every push and pull request:
- Linting with golangci-lint v2.3.0
- Tests on Go 1.23 and 1.24 with race detector
- Coverage reporting in pull requests
- Automatic coverage badge updates on main branch
-
Release Pipeline - Triggered on version tags (v*):
- Automated releases using GoReleaser
- Changelog generation
See .github/workflows/ for workflow configurations.
xlog provides flexible logger abstraction through adapters (ZapAdapter, SlogAdapter), allowing you to switch between different logging backends while maintaining a consistent API.
π Benchmarking: See BENCHMARKING.md for detailed performance guidelines and baseline metrics.
Adapter Overhead: The adapter layer introduces minimal overhead:
- Basic logging: ~2ns/op, 0 allocations
- Context reuse: ~2ns/op, 0 allocations (13% faster than baseline!)
- Context creation in loop: +2-3 allocations per call (avoid this pattern)
Key Performance Metrics:
- Logging with fields directly: ~2ns, 0 allocs
- WithFields (reused context): ~2ns, 0 allocs
- WithOperation (reused context): ~2ns, 0 allocs
- WithOperationSpan: ~670ns, 7 allocs
Follow these patterns to achieve optimal performance with zero allocations in hot paths:
The most important performance rule. Creating context in a loop causes significant overhead:
// β ANTI-PATTERN: Creates 4-7 allocations per iteration!
for i := 0; i < 1000; i++ {
ctx := xlog.WithOperation(baseCtx, "process-item") // 4 allocs, 64ns
processItem(ctx, items[i])
}
// β
BEST PRACTICE: 0 allocations, 32x faster
ctx := xlog.WithOperation(baseCtx, "process-batch") // One-time cost
for i := 0; i < 1000; i++ {
processItem(ctx, items[i]) // 0 allocs, 2ns
}Impact: Reusing context is 32x faster and produces zero allocations vs creating in loop.
For one-off log statements, pass fields directly (most efficient):
// β
BEST: 0 allocations
xlog.Info(ctx, "processing order",
field.String("order_id", orderID),
field.String("status", "pending"),
)When logging multiple times with the same fields, create enriched context once:
// β
GOOD: Create context once, reuse many times
ctx = xlog.WithFields(ctx,
field.String("user_id", userID),
field.String("session_id", sessionID),
)
// All subsequent logs include these fields with 0 allocations
xlog.Info(ctx, "user authenticated") // 0 allocs
xlog.Debug(ctx, "loading preferences") // 0 allocs
xlog.Info(ctx, "session started") // 0 allocsWithOperation creates a named logger (affects log namespace). Use only when needed:
// β
WHEN TO USE: You need operation in logger namespace
ctx = xlog.WithOperation(ctx, "payment-processor",
field.String("user_id", userID),
)
// β οΈ ALTERNATIVE: Use WithFields + operation field (same performance)
ctx = xlog.WithFields(ctx,
field.String("operation", "payment-processor"),
field.String("user_id", userID),
)Spans have moderate cost (~670ns, 7 allocs). Create them at appropriate granularity:
// β BAD: Span per row (thousands of allocations)
for row := range rows {
ctx, span := xlog.WithOperationSpan(ctx, "validate-row")
validate(row)
span.End()
}
// β
GOOD: One span for entire batch
ctx, span := xlog.WithOperationSpan(ctx, "validate-batch")
defer span.End()
for row := range rows {
validate(row) // 0 allocations
}Structured fields are more efficient and enable better log analysis:
// β οΈ Less efficient and harder to query
xlog.Infof(ctx, "user %s logged in from IP %s", userID, ip)
// β
Efficient and queryable
xlog.Info(ctx, "user logged in",
field.String("user_id", userID),
field.String("ip", ip),
)| Anti-Pattern | Impact | Fix |
|---|---|---|
WithOperation/WithFields in loop |
+4-7 allocs/iter, 32x slower | Create context once before loop |
WithOperationSpan per item |
+7 allocs/iter, 670ns | Create span for batch |
Printf-style logging |
String allocations | Use structured fields |
Unused enriched context |
Wasted allocations | Pass fields directly if logging once |
Based on comprehensive benchmarking with statistical significance (n=10):
| Operation | Time | Allocations | Use Case |
|---|---|---|---|
| Basic logging | ~2ns | 0 | Hot path logging |
| Context reuse | ~2ns | 0 | Multiple logs with same context |
| Context creation (WithOperation) | ~61ns | 4 | Setup phase only |
| Context creation (WithFields) | ~99ns | 5 | Setup phase only |
| WithOperationSpan | ~670ns | 7 | Request/operation boundaries |
Key Insight: The adapter overhead is negligible (~2ns) when using proper patterns (context reuse). Anti-patterns (create in loop) show 100% allocation increase.
- go.uber.org/zap - underlying logger
- go.opentelemetry.io/otel - OpenTelemetry tracing
- github.com/stretchr/testify - for testing