Skip to content
hermannmPublic

About

Utilities for structured logging in Go: pretty-formatted output, structured errors and context attributes.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

devlog

Go library that provides utilities for structured logging, building on the standard log/slog package. It consists of the following packages:

  • devlog provides a slog.Handler with a human-readable output format, designed for local development and CLI tools
  • errlog wraps a slog.Handler to transform logged errors into more structured attributes
  • ctxlog lets you attach log attributes to a context.Context, so that all logs in the scope of that context include those attributes in their output
  • sloginit provides utility functions for initializing the default slog handler with errlog's and ctxlog's wrapping handlers applied

Run go get hermannm.dev/devlog to add it to your project!

Docs: pkg.go.dev/hermannm.dev/devlog

Contents:

Usage

Using the devlog output handler

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):

Screenshot of log message in a terminal

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:

Screenshot of log message in a terminal

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)
}

Using errlog for structured error attributes

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.

Using ctxlog for context attributes

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.

Maintainer's guide

Publishing a new release

  • 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
  • Create commit and tag for the release (update TAG variable 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

Credits

About

Utilities for structured logging in Go: pretty-formatted output, structured errors and context attributes.

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages