Logging & Observability

Colored logs in dev, structured logs for tools, errors to Sentry. all from .env

Built on Go’s native slog. Colored and readable while you build, structured when a tool needs it, errors shipped to Sentry. Three env vars, zero code changes.

Three knobs

LOG_LEVEL=          # debug | info | warn | error  (empty = debug in dev, info in prod)
LOG_FORMAT=pretty   # pretty (colored, for humans) | json (for log tools)
LOG_HTTP=true       # HTTP request logging on/off

That’s the whole API. Everything below is just explaining when to reach for each.

LOG_LEVEL: how loud?

Decoupled from APP_ENV, so you can crank debug on a live server while chasing a bug. no redeploy.

LOG_LEVEL=debug   # see everything, including slog.Debug lines
LOG_LEVEL=warn    # only warnings and errors

Log chatty internals at Debug and they vanish at info, reappear on demand:

slog.Debug("cache miss", "key", key)   // hidden unless LOG_LEVEL=debug

LOG_FORMAT: for your eyes or for a tool?

pretty   08:27:37.781 CEST INF http request method=GET path=/docs status=200 duration_ms=29
json     {"time":"...","level":"INFO","msg":"http request","method":"GET","path":"/docs","status":200}
  • pretty (default): colored, aligned, human-readable. Colors drop automatically when stdout isn’t a terminal, so log files stay clean.
  • json: switch to this the moment a log aggregator scrapes your stdout (Loki, Datadog, Papertrail) and needs to parse fields like status>=500.

Reading fly logs / Railway with your eyes? Stay on pretty. Piping stdout into a tool? Flip to json. One line.

LOG_HTTP: request logs on or off?

Every request is logged with method, path, status, and duration. Static assets (/assets, /templui, /uploads, favicon) are always skipped so they don’t bury the real traffic.

Got a reverse proxy (Caddy, nginx, Traefik) that already logs requests? Set LOG_HTTP=false to kill the duplicate.

Errors to Sentry (optional)

SENTRY_DSN=https://[email protected]/xxx

Sentry is not affected by LOG_FORMAT. It runs as its own handler and receives only slog.Error calls with full stack traces, grouping, and alerts. So you get colored pretty console and Sentry at the same time, in dev or prod. Leave the DSN empty to disable.

Free tier: 5,000 errors/month. Grab a DSN at sentry.io.

Why no Loki/Datadog handler?

Those tools ingest your stdout (via the platform or an agent). that’s the standard, 12-factor way. So the lever for them is LOG_FORMAT=json, not a built-in push handler you’d have to configure and maintain. Sentry is different: error tracking is a push SDK by nature, so it gets its own handler.

Need direct push anyway? The logger fans out handlers via samber/slog-multi, so adding slog-loki or slog-datadog is ~5 lines in internal/logger/logger.go. The door’s open, it’s just not in your way.

Usage

slog.Info("user logged in", "user_id", user.ID, "email", user.Email)
slog.Warn("rate limit near", "ip", ip, "count", n)
slog.Error("failed to connect", "error", err, "host", dbHost)   // also goes to Sentry

Where it’s wired

One call in cmd/server/main.go, driven by config:

logger.Init(logger.Options{
    Level:     cfg.LogLevel,   // LOG_LEVEL
    Format:    cfg.LogFormat,  // LOG_FORMAT
    IsDev:     cfg.IsDevelopment(),
    SentryDSN: cfg.SentryDSN,  // SENTRY_DSN
})

Colored output uses lmittmann/tint. Everything else is standard-library slog.