Go library that provides utilities for structured logging, building on the standard
log/slog package. It consists of the following packages:
devlogprovides aslog.Handlerwith a human-readable output format, designed for local development and CLI toolserrlogwraps aslog.Handlerto transform logged errors into more structured attributesctxloglets you attach log attributes to acontext.Context, so that all logs in the scope of that context include those attributes in their outputsloginitprovides utility functions for initializing the defaultsloghandler witherrlog's andctxlog's wrapping handlers applied
Run go get hermannm.dev/devlog to add it to your project!
Docs: pkg.go.dev/hermannm.dev/devlog
Contents:
devlog.Handler implements slog.Handler, so it can handle
output for slog's logging functions. It can be configured as follows:
import (
"log/slog"
"hermannm.dev/devlog"
"hermannm.dev/devlog/sloginit"
)
func main() {
slog.SetDefault(slog.New(devlog.NewHandler(os.Stdout, nil)))
// Alternatively, use the sloginit package:
sloginit.InitPrettyLogHandler(os.Stdout, nil)
}Logging with slog will now use this handler. So the following log:
slog.Info("Server started", "port", 8000, "environment", "DEV")...will give the following output (using a gruvbox terminal color scheme):
Structs, slices and other non-primitive types are encoded as pretty-formatted JSON, so this example:
type Event struct {
ID int `json:"id"`
Type string `json:"type"`
}
event := Event{ID: 1000, Type: "ORDER_UPDATED"}
slog.Error("Failed to process event", "event", event)...gives this output:
devlog's output is meant to be easily read by a developer working locally. However, you may want a
more structured format for production systems. You can get both by conditionally choosing the log
handler for your application, like this:
switch os.Getenv("ENVIRONMENT") {
case "LOCAL", "TEST":
// Pretty-formatted logs for local development and tests
sloginit.InitPrettyLogHandler(os.Stdout, nil)
default:
// Structured JSON logs for deployed environments
sloginit.InitJSONLogHandler(os.Stdout, nil)
}errlog transforms slog.Attrs with error values to give them more structure. You use it by
wrapping your slog.Handler with errlog.NewHandler:
import (
"log/slog"
"os"
"hermannm.dev/devlog/errlog"
"hermannm.dev/devlog/sloginit"
)
func main() {
slog.SetDefault(
slog.New(
errlog.NewHandler(
slog.NewJSONHandler(os.Stdout, nil),
),
),
)
// Alternatively, use sloginit, which applies errlog.NewHandler for you:
sloginit.InitJSONLogHandler(os.Stdout, nil)
}Now, when you log errors with slog like this:
import (
"errors"
"fmt"
"log/slog"
)
func main() {
if err := fallibleFunction(); err != nil {
slog.Error("Something went wrong", "error", err)
}
}
func fallibleFunction() {
if err := innerFunction(); err != nil {
return fmt.Errorf("inner function failed: %w", err)
}
}
func innerFunction() {
return errors.New("root cause")
}...you get the following JSON output:
{
"time": "...",
"level": "ERROR",
"msg": "Something went wrong",
"error": {
"msg": "inner function failed",
"cause": {
"msg": "root cause"
}
}
}errlog also has special support for the
hermannm.dev/wrap library, which lets you attach
slog.Attrs to errors (using
wrap.ErrorWithAttrs). errlog includes
such extra attrs next to error.msg in the log output.
ctxlog provides ctxlog.WithAttrs, a function for adding log attributes to a
context.Context. In order to use this, you must first wrap your
slog.Handler with ctxlog.NewHandler:
import (
"log/slog"
"os"
"hermannm.dev/devlog/ctxlog"
"hermannm.dev/devlog/sloginit"
)
func main() {
slog.SetDefault(
slog.New(
ctxlog.NewHandler(
slog.NewJSONHandler(os.Stdout, nil),
),
),
)
// Alternatively, use sloginit, which applies ctxlog.NewHandler for you:
sloginit.InitJSONLogHandler(os.Stdout, nil)
}Now you can add context attributes with ctxlog.WithAttrs:
func processEvent(ctx context.Context, event Event) {
ctx = ctxlog.WithAttrs(ctx, "eventId", event.ID)
slog.InfoContext(ctx, "Processing event")
// ...
slog.InfoContext(ctx, "Successfully processed event")
}...giving this output:
{ "time":"...", "level": "INFO", "msg": "Processing event", "eventId": 1000 }
{ "time":"...", "level": "INFO", "msg": "Successfully processed event", "eventId": 1000 }This can help you trace all logs in the scope of this event's processing, by filtering on the
eventId in your log analysis tool. You may want to use OpenTelemetry
for more comprehensive tracing, but ctxlog lets you get basic tracing with just the standard
slog package.
- Run tests and linter (
golangci-lint):go test ./... && golangci-lint run - Add an entry to
CHANGELOG.md(with the current date)- Remember to update the link section, and bump the version for the
[Unreleased]link
- Remember to update the link section, and bump the version for the
- Create commit and tag for the release (update
TAGvariable in below command):TAG=vX.Y.Z && git commit -m "Release ${TAG}" && git tag -a "${TAG}" -m "Release ${TAG}" && git log --oneline -2 - Push the commit and tag:
git push && git push --tags- Our release workflow will then create a GitHub release with the pushed tag's changelog entry
- Jonathan Amsterdam for his fantastic
Guide to Writing
slogHandlers

