Session 10 · Logging and configuration#

Watch the class#

The big idea#

When a program runs on a server nobody is watching it. Logs are its diary: what happened, when, and how serious. Configuration is its settings: things you want to change (a port, a minimum age, a log level) without editing and rebuilding the code. Together they make a program you can operate.

Everyday example: a ship. The captain’s log records events with their importance (“storm warning”, “engine failure”). The ship’s settings (speed limit, course) come from the instruction sheet, not from rebuilding the ship.

In plain words

Use the standard log package for quick messages, or log/slog for structured logs with levels and key=value fields. Put settings in a YAML file, read it into a struct once at start-up.

Reach for it when:

  • the program runs unattended and you need to find out what went wrong

  • different machines need different settings

  • you want logs a tool can search (JSON)

Log levels act as a filter A logger drops everything below its level. At Info, Debug lines disappear and Info, Warn and Error get through. Debug Info Warn Error threshold = Info dropped kept config.yaml logging: level: info slog.Info("login", "user", "Syed") level=INFO msg=login user=Syed {"level":"INFO","msg":"login","user":"Syed"} Config as a struct YAML file → yaml.Unmarshal → Config{…} change behaviour without recompiling
A logger drops everything below its level. At Info, Debug lines disappear and Info, Warn and Error get through.

Standard logging with log and slog#

main.go: the output below has timestamps removed so it is repeatable; real logs include the time.

package main

import (
	"fmt"
	"log"
	"log/slog"
	"os"
)

func main() {
	// the plain standard logger: timestamp + message
	log.SetFlags(0) // drop the timestamp so the output is repeatable
	log.Println("server starting")
	log.SetPrefix("[app] ")
	log.Printf("listening on port %d", 8081)

	// slog: structured, with levels and key=value fields
	text := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: dropTime,
	}))
	text.Info("user logged in", "user", "Syed", "age", 28)
	text.Warn("validation failed", "field", "email")
	text.Debug("hidden: default level is Info")

	// the same call, JSON output for log tools
	js := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level:       slog.LevelDebug,
		ReplaceAttr: dropTime,
	}))
	js.Debug("now visible", "attempt", 2)
	js.With("app", "todo").Error("db down", "err", fmt.Errorf("timeout"))
}

func dropTime(groups []string, a slog.Attr) slog.Attr {
	if a.Key == slog.TimeKey {
		return slog.Attr{}
	}
	return a
}
level=INFO msg="user logged in" user=Syed age=28
level=WARN msg="validation failed" field=email
{"level":"DEBUG","msg":"now visible","attempt":2}
{"level":"ERROR","msg":"db down","app":"todo","err":"timeout"}
server starting
[app] listening on port 8081
  • log.Println writes to stderr with a timestamp (we turned it off). log.Fatal also exits.

  • slog takes a message plus alternating keys and values: "user", "Syed".

  • Levels: Debug < Info < Warn < Error. The default handler hides Debug.

  • Switch between human-friendly TextHandler and machine-friendly JSONHandler without changing the log calls.

  • With("app", "todo") stamps every later line with those fields.

The course’s logging library: logrus#

The repository uses the third-party logrus package. It works the same idea: levels and fields, with a JSON formatter. Add it with go get github.com/sirupsen/logrus.

logrus.go

package main

import (
	log "github.com/sirupsen/logrus"
)

func main() {
	log.SetFormatter(&log.JSONFormatter{DisableTimestamp: true})
	log.SetLevel(log.InfoLevel)

	log.WithField("app", "todo").Info("Application started")
	log.WithFields(log.Fields{"user": "Syed Jafer", "age": 28}).Warn("User validation failed")
	log.Debug("not shown at Info level")
}
{"app":"todo","level":"info","msg":"Application started"}
{"age":28,"level":"warning","msg":"User validation failed","user":"Syed Jafer"}

Newer projects often prefer slog because it ships with Go; logrus is still everywhere in existing code.

Reading a YAML config#

config.yaml

app:
  name: Todo Service
validation:
  min_age: 18
logging:
  level: info

config.go reads the file into a struct using gopkg.in/yaml.v3 (go get gopkg.in/yaml.v3).

package main

import (
	"fmt"
	"os"

	"gopkg.in/yaml.v3"
)

type Config struct {
	App struct {
		Name string `yaml:"name"`
	} `yaml:"app"`
	Validation struct {
		MinAge int `yaml:"min_age"`
	} `yaml:"validation"`
	Logging struct {
		Level string `yaml:"level"`
	} `yaml:"logging"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var cfg Config
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("parse %s: %w", path, err)
	}
	return &cfg, nil
}

func main() {
	cfg, err := LoadConfig("10-logging/config.yaml")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Printf("%+v\n", *cfg)
	fmt.Println(cfg.App.Name, cfg.Validation.MinAge)

	_, err = LoadConfig("nope.yaml")
	fmt.Println(err)
}
{App:{Name:Todo Service} Validation:{MinAge:18} Logging:{Level:info}}
Todo Service 18
open nope.yaml: no such file or directory

The yaml:"min_age" tags work exactly like the JSON ones from Session 5. Return the config and an error; let main decide how to react to a missing or broken file.

Note

Third-party packages come from a go.mod file. go mod init myapp creates it, go get adds a package, and go mod tidy cleans up. The course code folder has a ready go.mod.

Common mistakes#

  • Logging secrets. Never log passwords, tokens or full card numbers.

  • Logging in a loop at Info level. Millions of lines hide the one that matters.

  • Unstructured messages like "user "+name+" failed". Use fields so you can search by user.

  • Hard-coding settings (ports, database passwords) in source. Read them from config or environment variables.

  • Not handling a missing config. Check the error and say which file failed.

Hands-on exercises#

Exercise 1 · Leveled output. Create a text slog logger writing to stdout at Warn level and log one Info and one Warn message. Only the Warn should show (drop the time attribute to keep it repeatable).

Solution
package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelWarn,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))
	logger.Info("starting")
	logger.Warn("disk almost full", "percent", 91)
}
level=WARN msg="disk almost full" percent=91

Exercise 2 · Parse inline YAML. Unmarshal the text port: 8081\nhost: localhost into a struct and print the address localhost:8081.

Solution
package main

import (
	"fmt"

	"gopkg.in/yaml.v3"
)

type Server struct {
	Host string `yaml:"host"`
	Port int    `yaml:"port"`
}

func main() {
	var s Server
	if err := yaml.Unmarshal([]byte("port: 8081\nhost: localhost"), &s); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("%s:%d\n", s.Host, s.Port)
}
localhost:8081

Exercise 3 · Environment override. Read APP_PORT from the environment, and fall back to "8081" when it is not set.

Solution
package main

import (
	"fmt"
	"os"
)

func getenv(key, fallback string) string {
	if v, ok := os.LookupEnv(key); ok {
		return v
	}
	return fallback
}

func main() {
	fmt.Println(getenv("APP_PORT_DEMO", "8081"))
}
8081

os.LookupEnv tells “unset” apart from “set to empty”.