12 KiB
Lesson 2 — Structured JSON Logging with slog
New Go concepts in this lesson: closures (the three-layer middleware pattern), variadic-style function calls, type aliases for interfaces. Review the "closures" section of
00-go-basics-2-functions-structs-pointers.mdbefore this one if middleware still feels confusing after Lesson 1.
Why this matters
Right now (end of Lesson 1), middleware.Logger from chi prints
human-readable text to your terminal. That's fine to read by eye, but if
you ever want to ship logs to something like Grafana Loki (via Grafana
Alloy), you want structured JSON — one JSON object per log line — so
you can filter and query by field (status=500, path="/login", etc.)
instead of parsing free-form text with regexes.
Go's standard library has had a structured logging package, log/slog,
since Go 1.21 — no third-party dependency needed.
Part A — standalone playground
Build understanding in isolation first, in a throwaway project:
mkdir ~/go-playground/slog-demo && cd ~/go-playground/slog-demo
go mod init slog-demo
main.go
package main
import (
"log/slog"
"os"
"time"
)
func main() {
// 1. A plain text logger (human-readable, default style)
textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
textLogger.Info("this is text format", "user", "hamid", "attempt", 1)
// 2. A JSON logger (what we want for Loki)
jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
jsonLogger.Info("this is json format", "user", "hamid", "attempt", 1)
// 3. Log levels
jsonLogger.Debug("debug message - hidden by default")
jsonLogger.Info("info message - shown")
jsonLogger.Warn("warn message - shown")
jsonLogger.Error("error message - shown", "err", "something broke")
// 4. Structured fields with types
jsonLogger.Info("user logged in",
slog.String("username", "hamid"),
slog.Int("user_id", 42),
slog.Duration("took", 150*time.Millisecond),
slog.Bool("success", true),
)
// 5. A logger with permanent fields attached
requestLogger := jsonLogger.With(
slog.String("request_id", "abc-123"),
slog.String("service", "go-simple-api"),
)
requestLogger.Info("handling request")
requestLogger.Info("finished request", slog.Int("status", 200))
// 6. Controlling minimum level explicitly
debugLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
debugLogger.Debug("now debug shows up because we set the level")
}
Run it:
go run .
What to notice:
slog.New(handler)— every logger is a*slog.Loggerwrapping a Handler, which decides output format and destination. SwapNewTextHandler↔NewJSONHandlerand everything else in your code stays identical — this is the interface/implementation split from Go Basics Part 3 in action: your code depends on*slog.Logger's methods (Info,Error, ...), not on which Handler is behind it.- By default,
Debug(...)calls are silently dropped unless you explicitly setLevel: slog.LevelDebuginHandlerOptions— that's why section 3's debug line doesn't print, but section 6's does. slog.String,slog.Int,slog.Duration,slog.Boolare typed field constructors. You can skip them and just pass raw"key", valuepairs (as in sections 1–2) andsloginfers the type, but explicit typing is slightly faster and safer in hot paths..With(...)(section 5) returns a new logger with those fields baked in permanently — every call onrequestLoggerafterward automatically includesrequest_idandservice. This is exactly the pattern we'll use per-request: attach a request ID once, log normally after that.
How to change a logger's level after it's created
You can't mutate the level on an existing logger directly — it lives
inside the Handler and is normally fixed at creation. The fix is
slog.LevelVar, a small mutable "box" for a level:
var level slog.LevelVar // defaults to LevelInfo
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &level, // pointer to the LevelVar, not a fixed value
}))
logger.Debug("hidden") // nothing prints, level is Info
level.Set(slog.LevelDebug) // change it later, anytime
logger.Debug("now visible") // this prints
HandlerOptions.Level accepts anything implementing a Leveler
interface (one method: Level() slog.Level). A plain slog.Level
implements it by returning itself (fixed forever); *slog.LevelVar also
implements it, but its Level() reads a value you can change at runtime
via .Set(). The handler re-checks the level on every log call.
Part B — apply it to the project
No new dependencies — log/slog is part of the standard library.
internal/logging/logger.go
package logging
import (
"log/slog"
"os"
)
func New() *slog.Logger {
level := slog.LevelInfo
if os.Getenv("LOG_LEVEL") == "debug" {
level = slog.LevelDebug
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
return slog.New(handler)
}
Matches Part A section 6 — JSON handler, level controlled by env var instead of hardcoded.
The middleware "three-layer function" pattern, explained from scratch
Before the request-logging middleware code, let's build up to it slowly,
since this shape (a function that takes some setup and returns a
func(http.Handler) http.Handler) will reappear for authentication in
Lesson 8.
Step 1 — the simplest possible middleware, no arguments:
func SimpleLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("before request")
next.ServeHTTP(w, r)
log.Println("after request")
})
}
- Takes
next(whatever handler comes after this one in the chain). - Returns a NEW
http.Handler.http.HandlerFunc(...)is a type conversion — it turns a plainfunc(w, r)into something satisfying thehttp.Handlerinterface (see Go Basics Part 3: interfaces are just "has the right method," andHandlerFuncis a built-in adapter that gives any matching function aServeHTTPmethod for free). - Code before
next.ServeHTTP(w, r)runs before the real request handling; code after runs after. - Usage:
r.Use(SimpleLogger)— no parentheses needed afterSimpleLogger, since we're passing the function itself, and it already has the exact shaper.Useexpects.
Step 2 — now we want to pass in a logger. r.Use() only accepts
func(http.Handler) http.Handler — no room for extra arguments. So we
wrap that shape inside ANOTHER function that takes the logger first:
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
// ^ takes the logger ^ returns the middleware shape
return func(next http.Handler) http.Handler {
// ^ THIS is the actual func(http.Handler) http.Handler chi wants
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ^ THIS is the real per-request logic
...
})
}
}
Three layers, each running at a different time:
| Layer | Runs when | Purpose |
|---|---|---|
RequestLogger(logger) |
Once, when building the router | Captures logger in a closure |
func(next http.Handler) http.Handler |
Once, when chi wires up the chain | Captures next in a closure |
func(w, r) {...} |
On every single HTTP request | Does the actual logging |
This is exactly the closure concept from Go Basics Part 2's
makeCounter example — each inner function "remembers" variables from
the outer function that created it, even after that outer function has
returned.
Usage: r.Use(RequestLogger(logger)) — note RequestLogger(logger) is a
function call, not a bare reference. It runs the outer layer
immediately and returns the middle layer, which is what actually gets
handed to r.Use().
internal/middleware/request_logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
chimw "github.com/go-chi/chi/v5/middleware"
)
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// record the time BEFORE the request is handled, so we can
// measure how long it took afterward
ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor)
// a plain http.ResponseWriter only lets you WRITE a
// status/body, not read it back afterward. This wraps it so
// ww.Status() and ww.BytesWritten() become available once the
// response has been sent.
next.ServeHTTP(ww, r)
// run the rest of the chain / the final handler. We pass ww
// (the wrapped writer), not w, so the wrapping actually
// captures what gets written downstream. Everything BELOW
// this line runs AFTER the response is done.
logger.Info("http_request",
slog.String("request_id", chimw.GetReqID(r.Context())),
// the RequestID middleware (earlier in the chain) stored
// an ID inside the request's context; we read it back
// here
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int("bytes", ww.BytesWritten()),
slog.Duration("duration_ms", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
)
})
}
}
We alias chimw "github.com/go-chi/chi/v5/middleware" in the import so it
doesn't collide with our own package's name (middleware).
internal/router/router.go (updated)
package router
import (
"log/slog"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"git.hamidsoltani.com/hamid/go-simple-api/internal/handlers"
"git.hamidsoltani.com/hamid/go-simple-api/internal/middleware"
)
func New(logger *slog.Logger) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(middleware.RequestLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(60 * time.Second))
r.Get("/health", handlers.Health)
return r
}
New now takes a *slog.Logger parameter — this is dependency
injection (see the main README/ARCHITECTURE docs): instead of the router
building its own logger internally, it receives one from main.go, so
the whole app shares exactly one logger instance.
cmd/api/main.go (updated)
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.hamidsoltani.com/hamid/go-simple-api/internal/config"
"git.hamidsoltani.com/hamid/go-simple-api/internal/logging"
"git.hamidsoltani.com/hamid/go-simple-api/internal/router"
)
func main() {
cfg := config.Load()
logger := logging.New()
r := router.New(logger)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
}
go func() {
logger.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down gracefully")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("forced shutdown", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
We swapped log.Printf/log.Fatalf for our structured logger. Note
logger.Info("server starting", "port", cfg.Port) — slog's convenience
methods also accept plain alternating key/value pairs (no slog.String
wrapper needed) when calling .Info/.Error directly; both styles
produce the same structured JSON. We replaced log.Fatalf with
logger.Error(...) + os.Exit(1), since log.Fatal writes plain text
and would break our "everything is JSON" goal.
Try it
go run ./cmd/api
curl http://localhost:8080/health
You should see JSON lines like:
{"time":"2026-07-15T10:00:00Z","level":"INFO","msg":"server starting","port":"8080"}
{"time":"2026-07-15T10:00:05Z","level":"INFO","msg":"http_request","request_id":"...","method":"GET","path":"/health","status":200,"bytes":16,"duration_ms":123000,"remote_addr":"127.0.0.1:54321"}
This is exactly the shape Grafana Alloy likes to scrape from container stdout and ship to Loki — one JSON object per line, consistent keys, no custom parsing needed.
Once both parts run cleanly, move to Lesson 3 — config & MySQL connection.