Skip to main content

Go Structured Logging with slog

Context

Go tooling requires production-grade logging with:

  • Structured fields for machine parsing
  • Context propagation through call stacks
  • High performance (minimal overhead)
  • Integration with observability systems
  • Standard library compatibility

Decision

Use slog (Go standard library) for structured logging with context management.

Rationale

Why slog (Go 1.21+ Standard Library)

  • Standard Library: No external dependency, guaranteed compatibility
  • Performance: Designed for high-throughput logging
  • Structured by Default: Key-value pairs, not string formatting
  • Context Integration: First-class context.Context support
  • Handlers: JSON, Text, and custom handlers
  • Levels: Debug, Info, Warn, Error
  • Attributes: Rich type support (string, int, bool, time, error, etc.)

Why NOT zap or logrus?

  • zap: Excellent performance, but slog is now in stdlib with comparable speed
  • logrus: Mature but slower, maintenance mode, superseded by slog
  • zerolog: Fast but non-standard API, less idiomatic

Context Propagation Pattern

Logging flows through context to maintain operation correlation:

// Add logger to context
ctx := log.WithContext(ctx, logger.With("operation", "migrate", "namespace", ns))

// Extract logger from context
logger := log.FromContext(ctx)
logger.Info("starting migration")

Logging Schema

Log Levels

  • Debug: Detailed diagnostic information (disabled in production)
  • Info: General informational messages (normal operations)
  • Warn: Warning conditions that don't prevent operation
  • Error: Error conditions that prevent specific operation

Standard Fields (always present)

{
"time": "2025-10-07T12:00:00Z",
"level": "info",
"msg": "migration completed",
"service": "prism-migrate",
"version": "1.0.0"
}

Contextual Fields (operation-specific)

{
"time": "2025-10-07T12:00:00Z",
"level": "info",
"msg": "migration completed",
"service": "prism-migrate",
"version": "1.0.0",
"namespace": "production",
"operation": "migrate",
"rows_migrated": 15234,
"duration_ms": 5230,
"workers": 8
}

Error Fields

{
"time": "2025-10-07T12:00:00Z",
"level": "error",
"msg": "migration failed",
"service": "prism-migrate",
"error": "backend unavailable",
"error_type": "ErrBackendUnavailable",
"namespace": "production",
"retry_count": 3
}

Implementation Pattern

Package Structure

tools/internal/ log/ log.go # slog wrapper with context helpers context.go # Context management log_test.go # Tests


### Core API

package log

import ( "context" "log/slog" "os" )

var global *slog.Logger

// Init initializes the global logger func Init(level slog.Level, format string) error { var handler slog.Handler

switch format {
case "json":
handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
AddSource: level == slog.LevelDebug,
})
case "text":
handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
AddSource: level == slog.LevelDebug,
})
default:
return fmt.Errorf("unknown log format: %s", format)
}

// Add service metadata
handler = withServiceMetadata(handler)

global = slog.New(handler)
slog.SetDefault(global)

return nil

}

// WithContext adds logger to context func WithContext(ctx context.Context, logger *slog.Logger) context.Context { return context.WithValue(ctx, loggerKey{}, logger) }

// FromContext extracts logger from context (or returns default) func FromContext(ctx context.Context) *slog.Logger { if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok { return logger } return slog.Default() }

// With adds fields to logger in context func With(ctx context.Context, args ...any) context.Context { logger := FromContext(ctx).With(args...) return WithContext(ctx, logger) }

type loggerKey struct{}


### Usage Examples

// Initialize at startup if err := log.Init(slog.LevelInfo, "json"); err != nil { panic(err) }

// Add operation context ctx := log.WithContext(ctx, slog.Default().With( "operation", "migrate", "namespace", namespace, ))

// Log with context logger := log.FromContext(ctx) logger.Info("starting migration")

// Add more fields ctx = log.With(ctx, "rows", count) log.FromContext(ctx).Info("migrated rows")

// Error logging logger.Error("migration failed", "error", err, "namespace", namespace, "retry", retry, )

// Debug logging (only in debug mode) logger.Debug("worker started", "worker_id", workerID, "queue_size", queueSize, )


### Performance-Critical Paths

For hot paths, use conditional logging:

if logger.Enabled(ctx, slog.LevelDebug) { logger.DebugContext(ctx, "processing item", "item_id", id, "batch", batchNum, ) }


### Testing Pattern

// Custom handler for testing type TestHandler struct { logs []slog.Record mu sync.Mutex }

func (h *TestHandler) Handle(ctx context.Context, r slog.Record) error { h.mu.Lock() defer h.mu.Unlock() h.logs = append(h.logs, r) return nil }

// Test example func TestMigrate_Logging(t *testing.T) { handler := &TestHandler{} logger := slog.New(handler) ctx := log.WithContext(context.Background(), logger)

// Code that logs
migrate(ctx, "test-namespace")

// Assert logs
if len(handler.logs) < 1 {
t.Error("expected at least 1 log entry")
}

}


## Logging Guidelines

### DO:
- Use structured fields, not string formatting
- Pass context through call stack
- Log errors with context (namespace, operation, etc.)
- Use appropriate log levels
- Include duration for operations
- Log at service boundaries (start/end of major operations)

### DON'T:
- Log in tight loops
- Log sensitive data (credentials, PII)
- Use global logger (use context instead)
- Format strings with %v (use structured fields)
- Log at Info level for internal function calls

## Log Level Guidelines

### Debug
- Internal function entry/exit
- Variable values during debugging
- Detailed state information
- **Disabled in production**

### Info
- Service start/stop
- Major operation start/complete
- Configuration loaded
- Summary statistics

### Warn
- Degraded performance
- Retryable errors
- Non-fatal issues

### Error
- Failed operations
- Unrecoverable errors
- Connection failures

## Consequences

### Positive

- Zero external dependencies (stdlib)
- Excellent performance
- First-class context support
- Structured logging enforced by API
- Easy testing with custom handlers
- Future-proof (Go stdlib commitment)

### Negative

- slog is relatively new (Go 1.21+)
- Basic functionality (no log rotation, sampling, etc.)

### Mitigations

- Require Go 1.25 (already planned)
- Use external tools for log aggregation (Fluentd, Logstash)

## References

- [slog Documentation](https://pkg.go.dev/log/slog)
- [slog Design Proposal](https://go.googlesource.com/proposal/+/master/design/56345-structured-logging.md)
- ADR-012: Go for Tooling
- ADR-008: Observability Strategy
- org-stream-producer ADR-011: Structured Logging

## Revision History

- 2025-10-07: Initial draft and acceptance (adapted from org-stream-producer)