loggy

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 11 Imported by: 0

README

loggy

Go Reference CI golangci-lint

A small, fast, dependency-free structured logger for Go with a chained, zero-allocation builder API.

l := loggy.New(loggy.WithFormat(loggy.JSONFormat))
l.Info().Str("user", "ada").Int("n", 3).Msg("request handled")
// {"time":"2026-07-24T10:30:00Z","level":"info","msg":"request handled","user":"ada","n":3}

Features

  • Zero allocations on the hot path — fields are encoded straight into a pooled buffer as you chain them; no per-call slice, no map, no reflection for common types.
  • No dependencies — standard library only.
  • Structured JSON or human-readable text (with automatic level colors on a terminal).
  • Chained builder API: l.Info().Str(...).Int(...).Msg(...).
  • Child loggers with pre-encoded persistent fields via With().
  • Context extraction (Ctx), hooks, samplers, caller and stack traces, a package-level default, and an opt-in lock-free concurrent write path.
  • Safe for concurrent use.

Install

go get github.com/subhanjanops/loggy

Requires Go 1.25+.

Usage

Levels
l.Debug().Msg("verbose detail")
l.Info().Int("status", 200).Msg("request handled")
l.Warn().Dur("took", 1200*time.Millisecond).Msg("slow query")
l.Error().Err(err).Msg("upstream failed")
l.Fatal().Msg("cannot continue") // logs then os.Exit(1)
l.Panic().Msg("invariant broken") // logs then panic(msg)

Set the threshold with WithLevel or SetLevel; check it cheaply with Enabled. Below-threshold calls are a no-op and allocate nothing.

Fields

Str, Int, Int64, Float64, Bool, Dur, Err, Stringer, and Any (reflection fallback for slices/maps/structs).

Child loggers
reqLog := l.With().Str("request_id", "r-123").Logger()
reqLog.Info().Msg("received") // includes request_id; parent is unchanged
Formats and color
loggy.New(loggy.WithFormat(loggy.TextFormat)) // TIME LEVEL [name] msg k=v ...
loggy.New(loggy.WithFormat(loggy.JSONFormat)) // {"time":...,"level":...}

Text output colorizes the level automatically when writing to a terminal (white=debug, green=info, yellow=warn, red=error). Force it with WithColor(true|false).

Context
l := loggy.New(loggy.WithContextExtractor(func(ctx context.Context, e *loggy.Event) {
	if id, ok := ctx.Value(traceKey).(string); ok {
		e.Str("trace_id", id)
	}
}))
l.Info().Ctx(ctx).Str("path", "/orders").Msg("handling request")

// Or carry a logger through a request:
ctx = loggy.WithContext(ctx, reqLog)
loggy.FromContext(ctx).Info().Msg("downstream work")
Hooks and sampling
loggy.WithHook(myHook)        // Fire(Entry) error, called per entry
loggy.WithSampler(mySampler)  // Allow(Entry) bool, drops entries
Options
Option Purpose
WithOutput(w) destination writer (default os.Stdout)
WithLevel(lvl) minimum level (default InfoLevel)
WithFormat(f) TextFormat or JSONFormat (default JSON)
WithName(name) logger name on every line
WithCaller(true) attach file:line
WithStackTrace(lvl) stack trace at/above lvl
WithColor(bool) force level colors on/off
WithHook(h) per-entry hook
WithSampler(s) volume control
WithTimeFunc(fn) custom clock (deterministic tests)
WithContextExtractor(fn) fields from context.Context
WithConcurrentWriter() lock-free writes (see below)

Concurrency

All methods are safe for concurrent use. By default writes are guarded by a mutex so any io.Writer works. If your writer is itself concurrency-safe (os.File, os.Stdout/Stderr, io.Discard, or one you've synchronized), pass WithConcurrentWriter() to drop the lock for higher parallel throughput.

Performance

Benchmarked against zerolog and zap, JSON to io.Discard (AMD Ryzen 7 7435HS, Go 1.25). Full suite in bench/:

Scenario loggy zerolog zap
No fields 141 ns · 0 B · 0 allocs 118 ns · 0/0 243 ns · 0/0
3 fields 202 ns · 0 B · 0 allocs 164 ns · 0/0 453 ns · 192 B/1
10 fields 410 ns · 0 B · 0 allocs 343 ns · 0/0 818 ns · 705 B/1
Accumulated context + fields 209 ns · 0/0 229 ns · 0/0 493 ns · 192 B/1
Disabled + fields 4.5 ns · 0/0 7.6 ns · 0/0 62 ns · 192 B/1
With caller 669 ns 1108 ns 947 ns
Concurrent (WithConcurrentWriter) 21 ns · 0/0 21 ns · 0/0 114 ns · 128 B/1

loggy allocates zero on every enabled and disabled path (matching zerolog), beats zap across the board, and beats zerolog on accumulated-context and disabled workloads.

Run them yourself:

cd bench && go test -run '^$' -bench . -benchmem

Contributing

Contributions are welcome — see CONTRIBUTING.md. In short: keep the library dependency-free and allocation-free on the hot path, run gofmt/go vet/golangci-lint run/go test -race, and include benchmark numbers for hot-path changes.

CI enforces formatting, go vet, golangci-lint (config in .golangci.yml), and the race-enabled test suite on every push and pull request.

License

MIT © 2026 Subhanjan Adhikary

Documentation

Overview

Package loggy is a small, fast, dependency-free structured logger with a chained, zero-allocation builder API.

l := loggy.New(loggy.WithFormat(loggy.JSONFormat))
l.Info().Str("user", "ada").Int("n", 3).Msg("request handled")
// {"time":"...","level":"info","msg":"request handled","user":"ada","n":3}

Design

The public surface is interface-driven (Logger, Entry, Hook, Sampler) with a concrete *Event and *Context builder on the hot path for speed. Fields are encoded straight into a pooled buffer as they are chained — no per-call slice, no map, and no reflection for common types — so an enabled log costs zero heap allocations and a disabled one is a cheap no-op.

Levels

Six levels are available: Debug, Info, Warn, Error, Fatal, and Panic. Fatal logs then calls os.Exit(1); Panic logs then panics with the message. The active threshold is set with WithLevel or SetLevel and checked cheaply via Enabled.

Child loggers

With begins a child-logger builder that carries pre-encoded persistent fields:

reqLog := l.With().Str("request_id", "r-123").Logger()
reqLog.Info().Msg("received") // includes request_id

Formats and color

JSONFormat (default) emits one JSON object per line; TextFormat emits a human-readable line and, when writing to a terminal, colorizes the level.

Concurrency

All methods are safe for concurrent use. By default writes are guarded by a mutex so any io.Writer works. If the writer is itself concurrency-safe, pass WithConcurrentWriter to drop the lock for higher parallel throughput.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func ErrorPkg

func ErrorPkg(msg string)

ErrorPkg logs a message at ErrorLevel through the default logger.

func InfoPkg

func InfoPkg(msg string)

InfoPkg logs a message at InfoLevel through the default logger.

func SetDefault

func SetDefault(l Logger)

SetDefault replaces the package-level default Logger.

func WithContext

func WithContext(ctx context.Context, l Logger) context.Context

WithContext returns a copy of ctx carrying l, retrievable via FromContext.

Types

type Context

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

Context builds a child logger carrying pre-encoded persistent fields. Finish with Logger(). Field methods mirror Event's.

func (*Context) Any

func (c *Context) Any(key string, val any) *Context

Any adds a persistent arbitrary-value field to the child logger.

func (*Context) Bool

func (c *Context) Bool(key string, val bool) *Context

Bool adds a persistent bool field to the child logger.

func (*Context) Dur

func (c *Context) Dur(key string, d time.Duration) *Context

Dur adds a persistent duration field to the child logger.

func (*Context) Err

func (c *Context) Err(err error) *Context

Err adds a persistent error field (key "error") to the child logger.

func (*Context) Float64

func (c *Context) Float64(key string, val float64) *Context

Float64 adds a persistent float64 field to the child logger.

func (*Context) Int

func (c *Context) Int(key string, val int) *Context

Int adds a persistent int field to the child logger.

func (*Context) Int64

func (c *Context) Int64(key string, val int64) *Context

Int64 adds a persistent int64 field to the child logger.

func (*Context) Logger

func (c *Context) Logger() Logger

Logger returns a child Logger carrying the accumulated fields. It shares the parent's writer, hooks, sampler, and write lock, and does not mutate the parent.

func (*Context) Str

func (c *Context) Str(key, val string) *Context

Str adds a persistent string field to the child logger.

func (*Context) Stringer

func (c *Context) Stringer(key string, val fmt.Stringer) *Context

Stringer adds a persistent Stringer field to the child logger.

type ContextExtractor

type ContextExtractor func(ctx context.Context, e *Event)

ContextExtractor pulls values out of a context.Context and adds them to the event — e.g. attaching trace_id/span_id. It writes directly through the Event's chainable methods, so no intermediate field type is needed:

func(ctx context.Context, e *Event) {
    if id, ok := ctx.Value(traceKey).(string); ok {
        e.Str("trace_id", id)
    }
}

type Entry

type Entry interface {
	Time() time.Time
	Level() Level
	Message() string
	Caller() string
	Stack() string
}

Entry is one assembled log record, handed to Hooks and Samplers before it is written. Fields are not exposed here: on the streaming builder path they are already encoded to bytes, so an Entry carries only the record's scalar metadata.

type Event

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

Event is a chained, zero-allocation log entry. Fields are encoded directly into a pooled buffer as they are added; the terminal Msg/Msgf writes the line and recycles the Event. Begin one with a Logger level method (l.Info() etc).

An Event must not be used after Msg/Msgf. When the level is disabled the level method returns a shared no-op Event whose methods do nothing.

func (*Event) Any

func (e *Event) Any(key string, val any) *Event

Any adds an arbitrary value, falling back to reflection (json.Marshal / fmt) for types without a dedicated method.

func (*Event) Bool

func (e *Event) Bool(key string, val bool) *Event

Bool adds a bool field.

func (*Event) Ctx

func (e *Event) Ctx(ctx context.Context) *Event

Ctx runs the logger's ContextExtractor (if any) against ctx, letting it add fields such as a trace id to this event.

func (*Event) Dur

func (e *Event) Dur(key string, d time.Duration) *Event

Dur adds a duration field: nanoseconds in JSON, a string like 250ms in text.

func (*Event) Err

func (e *Event) Err(err error) *Event

Err adds the error under the key "error" (null/<nil> when err is nil).

func (*Event) Float64

func (e *Event) Float64(key string, val float64) *Event

Float64 adds a float64 field. NaN and ±Inf are rendered as strings in JSON.

func (*Event) Int

func (e *Event) Int(key string, val int) *Event

Int adds an int field.

func (*Event) Int64

func (e *Event) Int64(key string, val int64) *Event

Int64 adds an int64 field.

func (*Event) Msg

func (e *Event) Msg(msg string)

Msg writes the entry with the given message and finishes the event.

func (*Event) Msgf

func (e *Event) Msgf(format string, args ...any)

Msgf formats the message with fmt.Sprintf, then writes the entry.

Example
package main

import (
	"os"
	"time"

	"github.com/subhanjanops/loggy"
)

// fixedClock keeps example output deterministic.
func fixedClock() loggy.Option {
	return loggy.WithTimeFunc(func() time.Time {
		return time.Date(2026, 7, 24, 10, 30, 0, 0, time.UTC)
	})
}

func main() {
	l := loggy.New(loggy.WithOutput(os.Stdout), loggy.WithFormat(loggy.JSONFormat), fixedClock())
	l.Warn().Int("pct", 87).Msgf("%d%% of quota used", 87)
}
Output:
{"time":"2026-07-24T10:30:00Z","level":"warn","msg":"87% of quota used","pct":87}

func (*Event) Str

func (e *Event) Str(key, val string) *Event

Str adds a string field.

func (*Event) Stringer

func (e *Event) Stringer(key string, val fmt.Stringer) *Event

Stringer adds a field rendered via the value's String method.

type Format

type Format int

Format selects how entries are rendered on the wire.

const (
	TextFormat Format = iota // human-readable line, colorized on a terminal
	JSONFormat               // one JSON object per line
)

The available output formats.

type Hook

type Hook interface {
	Fire(Entry) error
}

Hook lets external code react to every emitted entry (e.g. shipping errors to Sentry, incrementing metrics counters).

type Level

type Level int32

Level is the severity of a log entry. Higher values are more severe.

const (
	DebugLevel Level = iota // verbose diagnostic detail
	InfoLevel               // normal operational messages
	WarnLevel               // something unexpected but non-fatal
	ErrorLevel              // a failure that needs attention
	FatalLevel              // terminal failure (Fatal exits, Panic panics)
)

The available severity levels, in increasing order.

func (Level) String

func (l Level) String() string

String returns the lowercase name of the level, used by the encoders.

type Logger

type Logger interface {
	Debug() *Event
	Info() *Event
	Warn() *Event
	Error() *Event
	Fatal() *Event // Msg/Msgf then os.Exit(1)
	Panic() *Event // Msg/Msgf then panic(msg)

	// With begins a child-logger builder; finish it with Logger():
	//   child := l.With().Str("component", "db").Logger()
	With() *Context

	SetLevel(lvl Level)
	Level() Level
	Enabled(lvl Level) bool // cheap pre-check for hot paths
	Sync() error            // flush the underlying writer if it supports it
	Close() error           // close the underlying writer if it is an io.Closer
}

Logger is a structured logger. All methods are safe for concurrent use. The level methods begin a chained entry finished by Msg/Msgf.

func Default

func Default() Logger

Default returns the package-level default Logger, creating a plain one on first access.

func FromContext

func FromContext(ctx context.Context) Logger

FromContext returns the Logger stored in ctx, or the package default logger if none is present.

func New

func New(opts ...Option) Logger

New builds a Logger with sane defaults (stdout, InfoLevel, JSON, time.Now) and applies the given options in order.

Example
package main

import (
	"os"
	"time"

	"github.com/subhanjanops/loggy"
)

// fixedClock keeps example output deterministic.
func fixedClock() loggy.Option {
	return loggy.WithTimeFunc(func() time.Time {
		return time.Date(2026, 7, 24, 10, 30, 0, 0, time.UTC)
	})
}

func main() {
	l := loggy.New(loggy.WithOutput(os.Stdout), loggy.WithFormat(loggy.JSONFormat), fixedClock())
	l.Info().Str("user", "ada").Int("n", 3).Msg("request handled")
}
Output:
{"time":"2026-07-24T10:30:00Z","level":"info","msg":"request handled","user":"ada","n":3}

type Option

type Option func(*logger)

Option configures a Logger at construction time. It is deliberately opaque: its underlying func operates on the unexported *logger, so callers can only obtain Options from the WithX constructors below and pass them to New — they cannot forge one or reach into a Logger's internals.

func WithCaller

func WithCaller(enabled bool) Option

WithCaller enables attaching the "file:line" of the call site to each entry.

func WithColor

func WithColor(enabled bool) Option

WithColor forces ANSI level colors on (true) or off (false) for text output. The default is automatic: colors are emitted only when the output is a terminal, so files and pipes stay clean. JSON output is never colorized.

func WithConcurrentWriter

func WithConcurrentWriter() Option

WithConcurrentWriter declares that the output io.Writer is safe for concurrent Write calls and writes each buffer atomically. The logger then skips its internal write mutex, giving lock-free throughput under contention (each log line is a single Write, so the writer serializes them).

Enable this ONLY for such writers — os.File, os.Stdout/Stderr, io.Discard, or a writer you have wrapped with your own synchronization. Enabling it with a writer whose Write is not concurrency-safe (e.g. a bare bytes.Buffer) will interleave or corrupt output.

func WithContextExtractor

func WithContextExtractor(fn ContextExtractor) Option

WithContextExtractor sets the function used by Event.Ctx to pull fields out of a context.Context (e.g. a trace id).

func WithFormat

func WithFormat(f Format) Option

WithFormat selects TextFormat or JSONFormat. The default is JSONFormat.

func WithHook

func WithHook(h Hook) Option

WithHook registers a Hook invoked for every emitted entry. It may be called multiple times to add several hooks.

func WithLevel

func WithLevel(lvl Level) Option

WithLevel sets the minimum level to emit. The default is InfoLevel.

Example
package main

import (
	"os"
	"time"

	"github.com/subhanjanops/loggy"
)

// fixedClock keeps example output deterministic.
func fixedClock() loggy.Option {
	return loggy.WithTimeFunc(func() time.Time {
		return time.Date(2026, 7, 24, 10, 30, 0, 0, time.UTC)
	})
}

func main() {
	l := loggy.New(loggy.WithOutput(os.Stdout), loggy.WithFormat(loggy.JSONFormat),
		loggy.WithLevel(loggy.WarnLevel), fixedClock())
	l.Info().Msg("filtered out") // below Warn, dropped
	l.Warn().Msg("shown")
}
Output:
{"time":"2026-07-24T10:30:00Z","level":"warn","msg":"shown"}

func WithName

func WithName(name string) Option

WithName sets a logger name included on every line (as "logger" in JSON).

func WithOutput

func WithOutput(w io.Writer) Option

WithOutput sets the destination writer. The default is os.Stdout.

func WithSampler

func WithSampler(s Sampler) Option

WithSampler sets a Sampler that can drop entries before they are written.

func WithStackTrace

func WithStackTrace(minLevel Level) Option

WithStackTrace attaches a stack trace to entries at or above minLevel. The default threshold is FatalLevel.

func WithTimeFunc

func WithTimeFunc(fn func() time.Time) Option

WithTimeFunc overrides the clock used for timestamps. Handy for deterministic tests. The default is time.Now.

type Sampler

type Sampler interface {
	Allow(Entry) bool
}

Sampler decides whether a given entry should be emitted at all (rate limiting / log volume control).

Jump to

Keyboard shortcuts

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