Stage 7 · Master
Phase 1 — Project Foundation
Logging
Give every service one structured logging contract, built once in a shared module, before the first business log line is ever written.
Why Logging Is Decided Before Business Code Exists
main.go currently calls log.Printf and log.Fatalf — the standard library's unstructured logger. Every one of those calls will be replaced in this lesson, and every log line the organization service ever writes afterward, in this phase or in Phase 2, uses the same structured logger built here. Deciding this now means Phase 2's handler, service, and middleware code never has to choose a logging approach — it only has to choose what to log.
pkg/logging: One Constructor, Two Writers
This platform standardizes on zerolog rather than the standard library's log/slog: zerolog's chained event API avoids allocating a []any for every call site, and its level filtering happens before a disabled event ever touches a writer — both matter once every handler, service, and middleware in Phase 2 is logging on the request path.
// Package logging builds a single, structured zerolog.Logger every service
// in this platform uses. It knows two environments: a colorized console
// writer for a developer's terminal, raw JSON for anywhere logs are
// collected and parsed by machines.
package logging
import (
"io"
"os"
"github.com/rs/zerolog"
)
// New returns a logger tagged with the given service name. env selects the
// output format: "production" and "staging" write raw JSON straight to
// stdout; anything else (including an empty string) is wrapped in
// zerolog.ConsoleWriter for human-readable output, matching this service's
// own ORGANIZATION_ENV default of "development". zerolog.Logger is a small
// value type — every function in this platform accepts and stores it by
// value, never by pointer.
func New(service, env, level string) zerolog.Logger {
var writer io.Writer = os.Stdout
if env != "production" && env != "staging" {
writer = zerolog.ConsoleWriter{Out: os.Stdout}
}
return zerolog.New(writer).
Level(parseLevel(level)).
With().
Timestamp().
Str("service", service).
Str("env", env).
Logger()
}
// parseLevel falls back to Info for anything it does not recognize, rather
// than failing service startup over a log-level typo.
func parseLevel(level string) zerolog.Level {
switch level {
case "debug":
return zerolog.DebugLevel
case "warn":
return zerolog.WarnLevel
case "error":
return zerolog.ErrorLevel
default:
return zerolog.InfoLevel
}
}
Wiring zerolog.Logger Into the Composition Root
type Config struct {
Env string `env:"ORGANIZATION_ENV" env-default:"development"`
Port int `env:"ORGANIZATION_PORT" env-default:"8080"`
+ LogLevel string `env:"ORGANIZATION_LOG_LEVEL" env-default:"info"`
}
Only the new operational input is shown. pkg/config.Load automatically reads the added cleanenv tags; its loading and error behavior remain unchanged.
| Existing call | Replacement in main.go | Why |
|---|---|---|
| log.Printf("... listening ...") | logger.Info().Str("addr", srv.Addr).Msg("listening") | Produces stable message and structured address field |
| log.Fatalf("server stopped: %v", err) | logger.Error().Err(err).Msg("server stopped"); os.Exit(1) | Keeps the error queryable and preserves a non-zero exit |
| log.Println("... shut down cleanly") | logger.Info().Msg("shut down cleanly") | Uses the shared service/env fields automatically |
The composition root and graceful-shutdown flow were completed in Dependency Injection, so the full file is not printed again. Configuration failure still writes to stderr because the configured logger cannot exist until configuration succeeds.
Fields That Must Always Be Present on Every Log Line
The .With().Str("service", service).Str("env", env).Logger() chain inside pkg/logging.New guarantees every single log line this service ever emits — whether written here in Phase 1 or in a request-handling middleware built in Phase 2 — carries service and env fields. Phase 2's Logging lesson adds a third always-present field, request_id, but only to logger instances derived per-request; it cannot be added here because no request exists yet at this point in the program.
parseLevel's default case exists deliberately: a typo like ORGANIZATION_LOG_LEVEL=verbos should not prevent the service from starting the way an invalid ORGANIZATION_PORT does in the Configuration Management lesson. Log level is an operational tuning knob, not a correctness-critical value — the two lessons make different fail-fast decisions for different reasons.
Comparing Console and JSON Output Side by Side
# ORGANIZATION_ENV=development
2024-01-01T09:00:00+00:00 INF listening addr=:8080 env=development service=organization
# ORGANIZATION_ENV=production
{"level":"info","service":"organization","env":"production","time":"2024-01-01T09:00:00Z","addr":":8080","message":"listening"}
Applied exercise
Prove the log-level default is a deliberate design decision, not an accident
Configuration Management makes bad input fatal; this lesson makes bad input degrade instead. Confirm the difference is real.
- Run the service with ORGANIZATION_LOG_LEVEL=verbose (an invalid value) and confirm it still starts, defaulting to info level.
- Add a temporary logger.Debug().Msg("this should not appear") call right after logger is constructed and confirm it is suppressed at the default info level.
- Rerun with ORGANIZATION_LOG_LEVEL=debug and confirm the same line now appears.
- Remove the temporary Debug call.
Deliverable
A short note contrasting this lesson's fail-soft behavior for log level with the previous lesson's fail-fast behavior for port, and why each choice fits its field.
Completion checks
- The invalid log level never prevents startup.
- The Debug line's visibility changes correctly between the two log-level runs.
- The temporary Debug call is removed before finishing.
Which field does every log line from pkg/logging.New carry, regardless of what code emits it?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.