slogjson

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2025 License: MIT Imports: 17 Imported by: 1

README

slog-json

tag Go Version GoDoc Build Status Go report Coverage Contributors License

Format your Golang structured logging (slog) using the JSON v2 library, with optional single-line pretty-printing.

This is so much easier to read than the default json:

{"time":"2000-01-02T03:04:05Z", "level":"INFO", "msg":"m", "attr":{"nest":1234}}

or

{"time": "2000-01-02T03:04:05Z", "level": "INFO", "msg": "m", "attr": {"nest": 1234}}

Versus the default standard library JSON Handler:

{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"m","attr":{"nest":"1234"}}

Additional benefits:

  • JSON v2 is faster than the stdlib JSON v1 (up to 9x faster).
  • Can make use of all marshaling and encoding options JSON v2 has available.
  • Improved correctness and behavior with JSON v2.
Other Great SLOG Utilities
  • slogctx: Add attributes to context and have them automatically added to all log lines. Work with a logger stored in context.
  • slogotel: Automatically extract and add OpenTelemetry TraceID's to all log lines.
  • sloggrpc: Instrument GRPC with automatic logging of all requests and responses.
  • slogdedup: Middleware that deduplicates and sorts attributes. Particularly useful for JSON logging. Format logs for aggregators (Graylog, GCP/Stackdriver, etc).
  • slogbugsnag: Middleware that pipes Errors to Bugsnag.
  • slogjson: Formatter that uses the JSON v2 library, with optional single-line pretty-printing.

Install

go get github.com/veqryn/slog-json

import (
	slogjson "github.com/veqryn/slog-json"
)

Usage

package main

import (
	"log/slog"
	"os"

	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
	slogjson "github.com/veqryn/slog-json"
)

func main() {
	h := slogjson.NewHandler(os.Stdout, &slogjson.HandlerOptions{
		AddSource:   false,
		Level:       slog.LevelInfo,
		ReplaceAttr: nil, // Same signature and behavior as stdlib JSONHandler
		JSONOptions: json.JoinOptions(
			// Options from the json v2 library (these are the defaults)
			json.Deterministic(true),
			jsontext.AllowDuplicateNames(true),
			jsontext.AllowInvalidUTF8(true),
			jsontext.EscapeForJS(true),
			jsontext.SpaceAfterColon(false),
			jsontext.SpaceAfterComma(true),
		),
	})
	slog.SetDefault(slog.New(h))

	slog.Info("hello world")
	// {"time":"2024-03-18T03:27:20Z", "level":"INFO", "msg":"hello world"}

	slog.Error("oh no!", slog.String("foo", "bar"), slog.Int("num", 98), slog.Any("custom", Nested{Nest: "my value"}))
	// {"time":"2024-03-18T03:27:20Z", "level":"ERROR", "msg":"oh no!", "foo":"bar", "num":98, "custom":{"nest":"my value"}}
}

type Nested struct {
	Nest any `json:"nest"`
}

Complex Usage

package main

import (
	"log/slog"
	"os"
	"time"

	"github.com/go-json-experiment/json"
	"github.com/go-json-experiment/json/jsontext"
	slogmulti "github.com/samber/slog-multi"
	slogctx "github.com/veqryn/slog-context"
	slogotel "github.com/veqryn/slog-context/otel"
	slogdedup "github.com/veqryn/slog-dedup"
	slogjson "github.com/veqryn/slog-json"
	"google.golang.org/protobuf/encoding/protojson"
)

func main() {
	// slogmulti chains slog middlewares
	slog.SetDefault(slog.New(slogmulti.
		// slogctx allows putting attributes into the context and have them show up in the logs,
		// and allows putting the slog logger in the context as well.
		Pipe(slogctx.NewMiddleware(&slogctx.HandlerOptions{
			Appenders: []slogctx.AttrExtractor{
				slogctx.ExtractAppended,
				slogotel.ExtractTraceSpanID, // Automatically add the OTEL trace/span ID to all log lines
			},
		})).

		// slogdedup removes duplicates (which can cause invalid json)
		Pipe(slogdedup.NewOverwriteMiddleware(nil)).

		// slogjson uses the future json v2 golang stdlib package,
		// which is faster, more correct, and allows single-line-pretty-printing
		Handler(slogjson.NewHandler(os.Stdout, &slogjson.HandlerOptions{
			AddSource:   false,
			Level:       slog.LevelDebug,
			JSONOptions: jsonOpts,

			// ReplaceAttr intercepts some attributes before they are logged to change their format
			ReplaceAttr: replaceAttr(),
		})),
	))
}

const (
	// AWS Cloudwatch sorts by time as a string, so force it to a constant size
	RFC3339NanoConstantSize = "2006-01-02T15:04:05.000000000Z07:00"

	// AWS Cloudwatch has a limit of 256kb, GCP Stackdriver is 100kb, Azure is 32kb total and 8kb per field, docker is 16kb, many Java based systems have a max of 8221.
	// Since there can be multiple attributes and the truncation is happening per attribute, and it is a lot harder to control total length, set the field length a bit shorter.
	maxLogFieldLength = 4000
)

// json options to use by the log handler
var jsonOpts = json.JoinOptions(
	json.Deterministic(true),
	jsontext.AllowDuplicateNames(true), // No need to slow down the marshaller when our middleware is doing it for us already
	jsontext.AllowInvalidUTF8(true),
	jsontext.EscapeForJS(false),
	jsontext.SpaceAfterColon(false),
	jsontext.SpaceAfterComma(true),

	// WithMarshalers will handle values nested inside structs and slices.
	json.WithMarshalers(json.JoinMarshalers(
		// []byte's are unreadable, so cast to string.
		json.MarshalToFunc(func(encoder *jsontext.Encoder, b []byte) error {
			return encoder.WriteToken(jsontext.String(string(b)))
		}),

		// We like time.Duration to be written out a certain way
		json.MarshalToFunc(func(e *jsontext.Encoder, t time.Duration) error {
			return e.WriteToken(jsontext.String(t.String()))
		}),

		// Convert protobuf messages into JSON using the canonical protobuf<->json spec
		json.MarshalFunc((&protojson.MarshalOptions{UseProtoNames: true}).Marshal),
	)),
)

// replaceAttr returns a replacement function that will reformat the log time,
// as well as truncate very long attributes (>4000 bytes).
func replaceAttr() func([]string, slog.Attr) slog.Attr {
	truncator := slogjson.ReplaceAttrTruncate(maxLogFieldLength, jsonOpts)
	return func(groups []string, a slog.Attr) slog.Attr {
		// Output the top level time argument with a specific format,
		// Because AWS Cloudwatch sorts time as a string instead of as a time.
		if groups == nil && a.Value.Kind() == slog.KindTime {
			return slog.String(a.Key, a.Value.Time().Format(RFC3339NanoConstantSize))
		}

		// Truncate the attribute value if necessary
		return truncator(groups, a)
	}
}
slog-multi Middleware

This library can interoperate with github.com/samber/slog-multi, in order to easily setup slog workflows such as pipelines, fanout, routing, failover, etc.

slog.SetDefault(slog.New(slogmulti.
	Pipe(slogctx.NewMiddleware(&slogctx.HandlerOptions{})).
	Pipe(slogdedup.NewOverwriteMiddleware(&slogdedup.OverwriteHandlerOptions{})).
	Handler(slogjson.NewHandler(os.Stdout, &slogjson.HandlerOptions{})),
))
Benchmarks

Compared with the stdlib log/slog.JSONHandler using the encoding/json v1 package, this slogjson.Handler using JSON v2 is about 7% faster, using fewer bytes per op.

The benchmark code is identical; written by the Golang authors for slog handlers.

Benchmarks were run on an Macbook M1 Pro.

The underlying JSON v2 encoder is up to 9x faster than the stdlib v1 encoder, as seen in these benchmarks.

slogjson.Handler Benchmarks:

BenchmarkJSONHandler/defaults-10         	 1654575	       718.0 ns/op	       0 B/op	       0 allocs/op
BenchmarkJSONHandler/time_format-10      	  918249	      1258 ns/op	      56 B/op	       4 allocs/op
BenchmarkJSONHandler/time_unix-10        	 1000000	      1106 ns/op	      24 B/op	       3 allocs/op
BenchmarkPreformatting/separate-10         	 1662286	       714.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkPreformatting/struct-10           	 1685990	       717.8 ns/op	       0 B/op	       0 allocs/op
BenchmarkPreformatting/struct_file-10      	  540447	      2593 ns/op	       0 B/op	       0 allocs/op

slog.JSONHandler Benchmarks:

BenchmarkJSONHandler/defaults-10         	 1562847	       768.7 ns/op	       0 B/op	       0 allocs/op
BenchmarkJSONHandler/time_format-10      	  840888	      1349 ns/op	     152 B/op	       4 allocs/op
BenchmarkJSONHandler/time_unix-10        	 1000000	      1165 ns/op	     120 B/op	       3 allocs/op
BenchmarkPreformatting/separate-10         	 1550346	       778.6 ns/op	       0 B/op	       0 allocs/op
BenchmarkPreformatting/struct-10           	 1572177	       766.1 ns/op	       0 B/op	       0 allocs/op
BenchmarkPreformatting/struct_file-10      	  508678	      2631 ns/op	       0 B/op	       0 allocs/op

Documentation

Overview

Package slogjson lets you format your Golang structured logging log/slog usingthe JSON v2 library github.com/go-json-experiment/json, with optional single-line pretty-printing.

This is so much easier to read than the default json:

{"time":"2000-01-02T03:04:05Z", "level":"INFO", "msg":"m", "attr":{"nest":1234}}

or

{"time": "2000-01-02T03:04:05Z", "level": "INFO", "msg": "m", "attr": {"nest": 1234}}

Versus the default standard library JSON Handler:

{"time":"2000-01-02T03:04:05Z","level":"INFO","msg":"m","attr":{"nest":"1234"}}

Additional benefits:

  • JSON v2 is faster than the stdlib JSON v1 (up to 9x faster).
  • Can make use of all marshaling and encoding options JSON v2 has available.
  • Improved correctness and behavior with JSON v2. See: https://github.com/golang/go/discussions/63397

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func ReplaceAttrTruncate added in v0.5.0

func ReplaceAttrTruncate(maxLogFieldLength int, jsonOptions jsonv2.Options) func(group []string, a slog.Attr) slog.Attr

ReplaceAttrTruncate is a replacement function that examines attributes before they are logged and if necessary truncates them. AWS Cloudwatch has a limit of 256kb, GCP Stackdriver is 100kb, Azure is 32kb total and 8kb per field, docker is 16kb, some Java based systems have a max of 8221. Since there can be multiple fields, and it is a lot harder to control total length, set the field length a bit shorter.

Types

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler is a log/slog.Handler that writes Records to an io.Writer as line-delimited JSON objects.

func NewHandler

func NewHandler(w io.Writer, opts *HandlerOptions) *Handler

NewHandler creates a Handler that writes to w, using the given options. If opts is nil, the default options are used.

func (*Handler) Enabled

func (h *Handler) Enabled(_ context.Context, l slog.Level) bool

Enabled reports whether the handler handles records at the given level. The handler ignores records whose level is lower.

func (*Handler) Handle

func (h *Handler) Handle(_ context.Context, r slog.Record) error

Handle formats its argument [Record] as a JSON object.

If the Record's time is zero, the time is omitted. Otherwise, the key is "time" and the value is output as with json.Marshal.

The level's key is "level" and the value of [Level.String] is output.

If the AddSource option is set and source information is available, the key is "source", and the value is a record of type [Source].

The message's key is "msg".

To modify these or other attributes, or remove them from the output, use [HandlerOptions.ReplaceAttr].

Values are formatted using the provided json.Options, with two exceptions.

First, an Attr whose Value is of type error is formatted as a string, by calling its Error method, if the error type does not implement json.Marshaler or json.MarshalerTo, and the json options provided does not include any json.Marshalers for this type. This affects nested errors and errors present in slices as well.

Second, an encoding failure does not cause Handle to return an error. Instead, the error message is formatted as a string.

Each call to Handle results in a single serialized call to io.Writer.Write.

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(as []slog.Attr) slog.Handler

WithAttrs returns a new Handler whose attributes consists of h's attributes followed by attrs.

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

WithGroup returns a new Handler who will put any future attributes inside the group.

type HandlerOptions

type HandlerOptions struct {
	// AddSource causes the handler to compute the source code position
	// of the log statement and add a SourceKey attribute to the output.
	AddSource bool

	// Level reports the minimum record level that will be logged.
	// The handler discards records with lower levels.
	// If Level is nil, the handler assumes LevelInfo.
	// The handler calls Level.Level for each record processed;
	// to adjust the minimum level dynamically, use a LevelVar.
	Level slog.Leveler

	// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
	// The attribute's value has been resolved (see [Value.Resolve]).
	// If ReplaceAttr returns a zero Attr, the attribute is discarded.
	//
	// The built-in attributes with keys "time", "level", "source", and "msg"
	// are passed to this function, except that time is omitted
	// if zero, and source is omitted if AddSource is false.
	//
	// The first argument is a list of currently open groups that contain the
	// Attr. It must not be retained or modified. ReplaceAttr is never called
	// for Group attributes, only their contents. For example, the attribute
	// list
	//
	//     Int("a", 1), Group("g", Int("b", 2)), Int("c", 3)
	//
	// results in consecutive calls to ReplaceAttr with the following arguments:
	//
	//     nil, Int("a", 1)
	//     []string{"g"}, Int("b", 2)
	//     nil, Int("c", 3)
	//
	// ReplaceAttr can be used to change the default keys of the built-in
	// attributes, convert types (for example, to replace a `time.Time` with the
	// integer seconds since the Unix epoch), sanitize personal information, or
	// remove attributes from the output.
	ReplaceAttr func(groups []string, a slog.Attr) slog.Attr

	// JSONOptions is a set of options created with [json.JoinOptions] for
	// configuring the json v2 library.
	// If not configured, the defaults will be:
	// 	json.Deterministic(true),
	// 	json.DiscardUnknownMembers(false),
	// 	json.FormatNilMapAsNull(false),
	// 	json.FormatNilSliceAsNull(false),
	// 	json.MatchCaseInsensitiveNames(false),
	// 	json.OmitZeroStructFields(false),
	// 	json.StringifyNumbers(false),
	// 	json.RejectUnknownMembers(false),
	// 	jsontext.AllowDuplicateNames(true),
	// 	jsontext.AllowInvalidUTF8(true),
	// 	jsontext.EscapeForHTML(false),
	// 	jsontext.EscapeForJS(true),
	// 	jsontext.Multiline(false),
	// 	jsontext.PreserveRawStrings(false),
	// 	jsontext.ReorderRawObjects(false),
	// 	jsontext.SpaceAfterColon(false),
	// 	jsontext.SpaceAfterComma(true),
	// 	(no ident)
	JSONOptions jsontext.Options
}

HandlerOptions are options for a Handler. A zero HandlerOptions consists entirely of default values.

Directories

Path Synopsis
internal
buffer
Package buffer provides a pool-allocated byte buffer.
Package buffer provides a pool-allocated byte buffer.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL