Introduction

Uber's Zap logger uses buffered I/O for performance -- log entries are written to an in-memory buffer and flushed to disk periodically or when the buffer fills. When a Go application exits (normally or due to a signal), any log entries still in the buffer are lost unless logger.Sync() is called. This is particularly problematic during shutdown sequences where the most important logs -- "closing database connection", "flushing cache", "shutdown complete" -- are the last ones written and therefore most likely to be in the buffer. Missing these logs makes post-incident debugging significantly harder.

Symptoms

Application logs are missing the final entries:

bash
$ tail -20 /var/log/myapp/app.log
2024-03-15T10:23:40.123Z    INFO    Processing batch 42
2024-03-15T10:23:41.456Z    INFO    Batch 42 complete
2024-03-15T10:23:45.789Z    INFO    Received SIGTERM, shutting down
# Missing: "Database connection closed", "Cache flushed", "Shutdown complete"

The application code calls these log functions but they never appear in the log file:

go
func shutdown() {
    logger.Info("Shutting down")
    db.Close()
    logger.Info("Database connection closed")  // Never appears in log
    cache.Flush()
    logger.Info("Cache flushed")               // Never appears in log
}

Common Causes

  • Not calling logger.Sync() before exit: Buffered log entries never flushed
  • defer logger.Sync() not reached: Panic or os.Exit(1) skips deferred functions
  • os.Exit() bypasses defer: os.Exit() terminates immediately without running defers
  • Zap configured with buffer but small size: zapcore.BufferedWriteSyncer with large buffer
  • Signal handler does not call Sync: SIGTERM handler logs shutdown steps but never syncs
  • Logger replaced with NOP in tests: Test setup replaces logger but production code still references old logger

Step-by-Step Fix

Step 1: Always call Sync() with proper error handling

```go func main() { logger, _ := zap.NewProduction() defer func() { // Sync must be called with error check if err := logger.Sync(); err != nil { // stderr is always unbuffered fmt.Fprintf(os.Stderr, "failed to sync logger: %v\n", err) } }()

logger.Info("application starting") // ... application logic ... logger.Info("shutting down") // defer runs logger.Sync() automatically } ```

Step 2: Handle os.Exit() and panics

```go func main() { logger, _ := zap.NewProduction() sugar := logger.Sugar()

// Exit via function, not os.Exit() code := run(sugar) sugar.Sync() os.Exit(code) }

func run(sugar *zap.SugaredLogger) int { if err := runApp(); err != nil { sugar.Error("application failed", zap.Error(err)) return 1 } sugar.Info("application completed successfully") return 0 } ```

For panic recovery:

```go func main() { logger, _ := zap.NewProduction() defer logger.Sync()

defer func() { if r := recover(); r != nil { logger.Fatal("panic recovered", zap.Any("panic", r), zap.Stack("stack"), ) // Fatal calls Sync() internally before os.Exit(1) } }()

// ... application code ... } ```

Step 3: Sync in signal handler

```go func main() { logger, _ := zap.NewProduction() defer logger.Sync()

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop()

srv := startServer(ctx, logger)

<-ctx.Done() logger.Info("shutdown signal received")

// Sync before long shutdown operations logger.Sync()

if err := srv.Shutdown(context.Background()); err != nil { logger.Error("shutdown error", zap.Error(err)) }

logger.Info("shutdown complete") // defer logger.Sync() flushes the final messages } ```

Prevention

  • Always defer logger.Sync() immediately after logger creation
  • Never use os.Exit() in production code -- return an exit code from main() instead
  • Use logger.Fatal() for fatal errors -- it calls Sync() internally
  • Add a shutdown test that verifies the last log line appears in the log file
  • Configure zapcore.BufferedWriteSyncer with a reasonable buffer size (default 256KB)
  • Monitor log file growth in production to detect missing log entries