Try it Free

How We Migrated to Structured Logging with Go's slog Package

SendRec’s codebase had 130 log.Printf calls scattered across 30 files. Every log line was a format string — fine for reading in a terminal, but useless for filtering, querying, or alerting on specific fields. We migrated everything to Go’s built-in log/slog package in v1.54.0.

Before and after

The old logging looked like this:

log.Printf("summary-worker: AI generation failed for video %s: %v", videoID, err)
log.Printf("trim: completed for video %s", videoID)
log.Println("digest-worker: started")

Now it looks like this:

slog.Error("summary-worker: AI generation failed", "video_id", videoID, "error", err)
slog.Info("trim: completed", "video_id", videoID)
slog.Info("digest-worker: started")

The output changes from:

2026/02/23 12:00:00 summary-worker: AI generation failed for video abc123: context deadline exceeded

To:

time=2026-02-23T12:00:00.000Z level=ERROR msg="summary-worker: AI generation failed" video_id=abc123 error="context deadline exceeded"

Every value is now a named, structured field. You can grep for video_id=abc123 to find everything that happened to that video, or filter by level=ERROR to see only failures.

What we changed

Log levels. Every call got a level:

  • slog.Info — normal operations (startup, job completion, worker lifecycle)
  • slog.Error — failures (database errors, S3 failures, AI timeouts)
  • slog.Warn — skipped work or missing config (email not configured, insufficient transcript segments)

Structured fields. Format string interpolation became key-value pairs with consistent names: video_id, user_id, error, key (for S3 paths), attempt, max_attempts.

HTTP request logging. We replaced chi’s middleware.Logger with a custom slog middleware:

func slogMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path == "/api/health" {
            next.ServeHTTP(w, r)
            return
        }

        start := time.Now()
        recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
        next.ServeHTTP(recorder, r)

        slog.Info("http request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", recorder.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
            "remote_addr", r.RemoteAddr,
        )
    })
}

The middleware skips /api/health to reduce noise from health probes. The statusRecorder wrapper captures the response status code and implements Unwrap() so Go’s ResponseController can reach through to the underlying writer for streaming and flushing.

What we kept

log.Fatal and log.Fatalf stayed unchanged. These are startup-only calls that exit the process when required configuration is missing (no database URL, no JWT secret). They run before the structured logger is configured, and they terminate the process — structured output doesn’t matter for a message that’s immediately followed by os.Exit(1).

The migration process

This was a mechanical refactor — no logic changes, just logging calls. The approach:

  1. Build the HTTP middleware first (the only new code)
  2. Configure slog.SetDefault() in main.go
  3. Migrate the largest package first (internal/video/ — 17 files, 80+ calls)
  4. Migrate remaining packages (auth, email, billing, slack, webhook, notify, httputil)
  5. Verify no log.Printf/log.Println calls remain
  6. Run the full test suite and linter

The whole migration touched 30 files with 370 insertions and 223 deletions — mostly line-for-line replacements.

Try it

The change is live in v1.54.0. SendRec is open source (AGPL-3.0) — the middleware is in logging.go and the logger setup in main.go.