/logx

A blazing fast structured logger for Go

Primary LanguageGoMIT LicenseMIT

logx

A blazing fast structured logger for Go


Overview

After working on zlog, I've decided to do a second iteration of a structured logger with a simpler (but more meaningful) API, as well as a more performant solution.

As for the logger API, I followed most of the input shared in the discussion in go#54763, on what I saw was useful and idiomatic. As for the implementation itself, although it is not as clear-cut as desired or as performant as zerolog or zap, it is still going for a very low number of allocations for the amount of time put into. More information in the benchmarks section


Installation

To fetch logx as a Go library, use go get or go install:

go get -u github.com/zalgonoise/logx
go install github.com/zalgonoise/logx@latest

...or, simply import it in your Go file and run go mod tidy:

import (
    // (...)

    "github.com/zalgonoise/logx"
)

Features

Logger

The Logger is an interface that implements a Printer interface (with methods corresponding log printing actions like Log() and Info()) as well as a set of additional helper methods to make it easier to use and configure.

To spawn a Logger, you need to provide a Handler.

// Logger interface describes the behavior that a logger should
// have
//
// This includes the Printer interface, as well as other methods
// to give the logger more flexibility
type Logger interface {
	// Printer interface allows registering log messages
	Printer
	// Enabled returns a boolean on whether the logger is accepting
	// records with log level `level`
	Enabled(level level.Level) bool
	// Handler returns this Logger's Handler interface
	Handler() handlers.Handler
	// With will spawn a copy of this Logger with the input attributes
	// `attrs`
	With(attrs ...attr.Attr) Logger
}


// Printer interface describes the behavior that a (log) Printer
// should have. This includes individual methods for printing log
// messages for each log level, as well as a general-purpose `Log()`
// method to customize the log level.
type Printer interface {
	// Trace prints a log message `msg` with attributes `attrs`, with
	// Trace-level
	Trace(msg string, attrs ...attr.Attr)
	// Debug prints a log message `msg` with attributes `attrs`, with
	// Debug-level
	Debug(msg string, attrs ...attr.Attr)
	// Info prints a log message `msg` with attributes `attrs`, with
	// Info-level
	Info(msg string, attrs ...attr.Attr)
	// Warn prints a log message `msg` with attributes `attrs`, with
	// Warn-level
	Warn(msg string, attrs ...attr.Attr)
	// Error prints a log message `msg` with attributes `attrs`, with
	// Error-level
	Error(msg string, attrs ...attr.Attr)
	// Fatal prints a log message `msg` with attributes `attrs`, with
	// Fatal-level
	Fatal(msg string, attrs ...attr.Attr)
	// Log prints a log message `msg` with attributes `attrs`, with
	// `level` log level
	Log(level level.Level, msg string, attrs ...attr.Attr)
}

Handler

A handler is the logging backend, responsible for writing the records with a certain format using an io.Writer. This library exposes a basic text handler that formats any types as string simply using fmt.Sprintf("%v", r.Value()); as well as a JSON handler that uses goccy/go-json.

The text handler is not optimized for performance and is not exactly most suitable for production. The JSON handler is reliable, however, and it is safe to use in production.

The data structures implementing these handlers are immutable. The handler is a simple interface which can be implemented with the following methods:

// Handler describes a logging backend, capable of writing a Record to an
// io.Writer (with its Handle() method).
//
// Beyond this feature, it also exposes methods of copying it with different
// configuration options.
type Handler interface {
	// Enabled returns a boolean on whether the Handler is accepting
	// records with log level `level`
	Enabled(level level.Level) bool
	// Handle will process the input Record, returning an error if raised
	Handle(records.Record) error
	// With will spawn a copy of this Handler with the input attributes
	// `attrs`
	With(attrs ...attr.Attr) Handler

	// WithSource will spawn a new copy of this Handler with the setting
	// to add a source file+line reference to `addSource` boolean
	WithSource(addSource bool) Handler

	// WithLevel will spawn a copy of this Handler with the input level `level`
	// as a verbosity filter
	WithLevel(level level.Level) Handler

	// WithReplaceFn will spawn a copy of this Handler with the input attribute
	// replace function `fn`
	WithReplaceFn(fn func(a attr.Attr) attr.Attr) Handler
}

Record

A record is an interface exposes a set of getter methods for its elements, as well as additional helper methods to make it more granular. Although the built-in handlers already generate records themselves in their implementations, the point to the interface is to allow easy integration and extension of this library, with your own custom data types.

A record is an immutable entity.

// Record interface describes the behavior that a Record should have
//
// It expose getter methods for its elements, as well as two helper methods:
//   - `AddAttr()` will return a copy of this Record with the input Attr appended
//     to the existing ones
//   - `AttrLen()` will return the length of the attributes in the record
type Record interface {
	// AddAttr returns a copy of this Record with the input Attr appended to the
	// existing ones
	AddAttr(a ...attr.Attr) Record
	// Attrs returns the slice of Attr associated to this Record
	Attrs() []attr.Attr
	// AttrLen returns the length of the slice of Attr in the Record
	AttrLen() int
	// Message returns the string Message associated to this Record
	Message() string
	// Time returns the time.Time timestamp associated to this Record
	Time() time.Time
	// Level returns the level.Level level associated to this Record
	Level() level.Level
}

Attribute

An attribute is a simple interface that exposes getter and setter methods for an attribute, a key-value pair where the key is string and value is any. Note that an attribute is an immutable entity.

// Attr interface describes the behavior that a serializable attribute
// should have.
//
// Besides retrieving its key and value, it also permits creating a copy of
// the original Attr with a different key or a different value
type Attr interface {
	// Key returns the string key of the attribute Attr
	Key() string
	// Value returns the (any) value of the attribute Attr
	Value() any
	// WithKey returns a copy of this Attr, with key `key`
	WithKey(key string) Attr
	// WithValue returns a copy of this Attr, with value `value`
	//
	// It must be the same type of the original Attr, otherwise returns
	// nil
	WithValue(value any) Attr
}

Despite being exposed and used as an interface, creating a new attribute with attr.New[T](key string, value T) attr.Attr uses a generic function that scopes this attribute to a certain type.

This means that when copying an attribute with the WithValue() method, the input value (as type any) must match the original attribute's type.

Level

A level is an interface that exposes two methods, String() string and Int() int, which define different log levels in the records. While levels are used to resemble severity of the log record, they are also used by handlers (and likewise loggers) as a records filter.

// Level interface describes the behavior that a log level should have
//
// It must provide methods to be casted as a string or as an int
type Level interface {
	// String returns the level as a string
	String() string
	// Int returns the level as an int
	Int() int
}

Context Logger

A logger can be embeded into a context.Context, and retrieved from one, too:

// CtxLoggerKey is a custom type to define context keys for this
// library's logger
type CtxLoggerKey string

// StandardCtxKey is an instance of CtxLoggerKey with value "logger"
const StandardCtxKey CtxLoggerKey = "logger"

// InContext returns a copy of the input Context `ctx` with the input
// Logger `logger` as a value (identified by `StandardCtxKey`)
func InContext(ctx context.Context, logger Logger) context.Context

// From returns a Logger from the input Context `ctx`. If not present,
// it returns nil
func From(ctx context.Context) Logger

Disclaimer

Although logx isn't the world's fastest structured logger, I am not aiming for it either. In reality, logging should be kept simple and the right tools should be used for the job.

This means if you're concerned about metrics, setup your observability accordingly. If you need alerts on certain events, setup your observability accordingly.

All in all, logging is part of your observability strategy but it should not be the center point nor should it be the only tool in your toolbox for it.

I love structured logging but parsing millions of lines of logs to find a single event drives one not to use it as it is inteded. Keeping logging simple and just the right amount is key.

The point is, do not overburden your app with log entries as it will most certainly backfire, and you won't care about those logs. If you really need to retain big volumes of logs, surely you're using the right tool for it as well.

Getting this out of the way, let's crunch some numbers:


Benchmarks

Setting up a quick and easy benchmark test file similar to zlog's, in benchmark/benchmark_test.go:

# with `prettybench`:

goos: linux
goarch: amd64
pkg: github.com/zalgonoise/logx/benchmark
cpu: AMD Ryzen 3 PRO 3300U w/ Radeon Vega Mobile Gfx
PASS
coverage: [no statements]
benchmark                                       iter       time/iter   bytes alloc         allocs
---------                                       ----       ---------   -----------         ------
BenchmarkLogger/Writing/SimpleText/LogX-4    916345   1605.00 ns/op      537 B/op    4 allocs/op
BenchmarkLogger/Writing/SimpleJSON/LogX-4    644287   1824.00 ns/op      352 B/op    5 allocs/op
BenchmarkLogger/Writing/ComplexText/LogX-4   253484   4982.00 ns/op     1271 B/op   24 allocs/op
BenchmarkLogger/Writing/ComplexJSON/LogX-4   208918   5727.00 ns/op     1544 B/op   18 allocs/op
ok      github.com/zalgonoise/logx/benchmark   7.869s

When comparing these results to the vendor benchmark test in zlog's benchmarks summary, it's clear that there is a major improvement when comparing to zlog, as well as being close to zap in number of allocations. Adding the results above for context, in an ordered list of tests:

goos: linux
goarch: amd64
pkg: github.com/zalgonoise/zlog/benchmark
cpu: AMD Ryzen 3 PRO 3300U w/ Radeon Vega Mobile Gfx

PASS
coverage: [no statements]
benchmark                                                         iter        time/iter   bytes alloc         allocs
---------                                                         ----        ---------   -----------         ------
BenchmarkVendorLoggers/Writing/SimpleText/ZeroLogger-4         2570126     471.30 ns/op      156 B/op    0 allocs/op
BenchmarkVendorLoggers/Writing/SimpleText/StdLibLogger-4       2948067     412.70 ns/op       24 B/op    1 allocs/op
BenchmarkVendorLoggers/Writing/SimpleText/ZapLogger-4           827116    1396.00 ns/op       64 B/op    3 allocs/op
BenchmarkLogger/Writing/SimpleText/LogX-4    			916345    1605.00 ns/op      537 B/op    4 allocs/op
BenchmarkVendorLoggers/Writing/SimpleText/ZlogLogger-4          945510    1336.00 ns/op      368 B/op    9 allocs/op
BenchmarkVendorLoggers/Writing/SimpleText/LogrusLogger-4        273914    4253.00 ns/op      480 B/op   15 allocs/op

BenchmarkVendorLoggers/Writing/SimpleJSON/ZeroLogger-4         5817523     317.00 ns/op       92 B/op    0 allocs/op
BenchmarkVendorLoggers/Writing/SimpleJSON/ZapLogger-4          1000000    1152.00 ns/op        0 B/op    0 allocs/op
BenchmarkLogger/Writing/SimpleJSON/LogX-4    			644287    1824.00 ns/op      352 B/op    5 allocs/op
BenchmarkVendorLoggers/Writing/SimpleJSON/ZlogLogger-4          425534    2815.00 ns/op      376 B/op    6 allocs/op
BenchmarkVendorLoggers/Writing/SimpleJSON/LogrusLogger-4        203432    5298.00 ns/op     1080 B/op   22 allocs/op

BenchmarkVendorLoggers/Writing/ComplexText/ZeroLogger-4         382442    2906.00 ns/op      288 B/op   11 allocs/op
BenchmarkVendorLoggers/Writing/ComplexText/ZapLogger-4          171844    6609.00 ns/op      848 B/op   21 allocs/op
BenchmarkLogger/Writing/ComplexText/LogX-4   			253484    4982.00 ns/op     1271 B/op   24 allocs/op
BenchmarkVendorLoggers/Writing/ComplexText/ZlogLogger-4         121747   11129.00 ns/op     3756 B/op   50 allocs/op
BenchmarkVendorLoggers/Writing/ComplexText/LogrusLogger-4        71154   14105.00 ns/op     2168 B/op   43 allocs/op

BenchmarkVendorLoggers/Writing/ComplexJSON/ZeroLogger-4         388226    3722.00 ns/op      288 B/op   11 allocs/op
BenchmarkVendorLoggers/Writing/ComplexJSON/ZapLogger-4          231116    6320.00 ns/op      784 B/op   18 allocs/op
BenchmarkLogger/Writing/ComplexJSON/LogX-4   			208918    5727.00 ns/op     1544 B/op   18 allocs/op
BenchmarkVendorLoggers/Writing/ComplexJSON/ZlogLogger-4         115693   11486.00 ns/op     2680 B/op   40 allocs/op
BenchmarkVendorLoggers/Writing/ComplexJSON/LogrusLogger-4       116692   11029.00 ns/op     2592 B/op   44 allocs/op