Fluent structured logging for Go, built on top of log/slog.
log does not replace log/slog. It is a fluent layer that builds slog attributes and delegates the actual log writing to log/slog handlers.
log is designed for developers and teams that already use log/slog, rely heavily on context.Context to carry request-scoped data (trace IDs, user/session identifiers, etc.), and want structured logging with a fluent API and low overhead.
json: machine-friendly (default)text:time | level | trace | msg | k=v...(human-friendly)slog:log/slogtext handler (key=value)
timeandlevelare always present- output is structured (even in
text) - level filtering is enforced by the handler
Entry.Send()returnserror(write/handler errors)
go get github.com/jeffotoni/logpackage main
import (
"context"
"os"
"github.com/jeffotoni/log"
)
func main() {
ctx := log.WithCtx(context.Background()).
TraceID("abc123").
Str("X-User-ID", "user42").
Any("attempt", 3).
Context()
log.New(log.Config{Format: log.FormatJSON, Writer: os.Stdout, ServiceName: "api"}).
Info().
Ctx(ctx).
Str("component", "auth").
Msg("user login").
Send()
log.New(log.Config{Format: log.FormatText, Writer: os.Stdout, ServiceName: "api"}).
Info().
Ctx(ctx).
Str("component", "auth").
Msg("user login").
Send()
log.New(log.Config{Format: log.FormatSlog, Writer: os.Stdout, ServiceName: "api"}).
Info().
Ctx(ctx).
Str("component", "auth").
Msg("user login").
Send()
}Note: field ordering is not guaranteed, especially for fields imported from context.Context.
π¨οΈ Sample Output (text)
π£ TRACE
π΅ DEBUG
π’ INFO
π‘ WARN
π΄ ERROR
Sample output:
// json
{"time":"2026-01-27T16:04:16-03:00","level":"INFO","msg":"user login","service":"api","attempt":3,"traceId":"abc123","X-User-ID":"user42","component":"auth"}
// text
2026-01-27T16:04:16-03:00 | INFO | abc123 | user login | service=api | X-User-ID=user42 | attempt=3 | component=auth
// slog
time=2026-01-27T16:04:16-03:00 level=INFO msg="user login" service=api X-User-ID=user42 attempt=3 traceId=abc123 component=auth
Tip: run the repo demo:
go run ./examples/demopackage main
import "github.com/jeffotoni/log"
func main() {
err := log.New().
Info().
Str("component", "bootstrap").
Int("attempt", 1).
Msg("service started").
Send()
if err != nil {
panic(err)
}
}Send() returns nil on success and when the level is filtered out.
Handle the return when you need delivery guarantees to the configured writer.
if err := log.Info().
Str("component", "api").
Msg("ready").
Send(); err != nil {
// writer/handler failure
}Use NewCtx() to create a context.Context with string-only fields.
ctx, cancel := log.NewCtx().
Set("X-Trace-ID", "abc-123").
Set("X-User-ID", "user-42").
Set("X-Session-ID", "sess-999").
Timeout(10 * time.Second).
Build()
defer cancel()
traceID := log.CtxGet(ctx, "X-Trace-ID") // "abc-123"
userID := log.CtxGet(ctx, "X-User-ID") // "user-42"
fields := log.CtxGetAll(ctx) // map[string]string{...}Notes:
NewCtx(parentCtx)preserves cancel/deadline from the parent context.CtxGetAllreturns only the string fields stored byNewCtx().Set(...).
Use WithCtx(ctx) to attach typed values (int/bool/map/etc) to an existing context.
ctx := log.WithCtx(context.Background()).
Any("attempt", 3).
Bool("cached", true).
Str("role", "admin").
Context()
v, _ := log.CtxGetAny(ctx, "attempt") // 3
all := log.CtxGetAllAny(ctx) // map[string]any{...}Typed fields are cumulative across calls:
ctx1 := log.WithCtx(context.Background()).
Any("a", 1).
Context()
ctx2 := log.WithCtx(ctx1).
Any("b", 2).
Context()
all := log.CtxGetAllAny(ctx2) // map[string]any{"a":1,"b":2}Entry.Ctx(ctx) does two things:
- uses the provided
context.Contextin the underlyingslogcall - imports fields from context into the log entry (
string+typed)
ctx := log.WithCtx(context.Background()).
TraceID("abc123").
Str("X-User-ID", "user42").
Any("attempt", 3).
Context()
log := log.New(log.Config{Format: log.FormatJSON, ServiceName: "api"})
log.Info().
Ctx(ctx).
Msg("request").
Send()When a key exists in more than one source, conflicts follow a last-write-wins rule (consistent with log/slog).
In typical usage, precedence is:
- Fields set directly on the
Entry(Entry.Str/Any/Int/...) afterEntry.Ctx(ctx)override context-imported fields. - Typed context fields (from
WithCtx) override string context fields (fromNewCtx) when keys collide. - String context fields (from
NewCtx) have the lowest precedence.
Tip: set request-wide defaults in the context, call Entry.Ctx(ctx) to import them, then override per-log fields directly on the Entry when needed.
log treats empty keys ("") and empty string values differently depending on the API:
- Empty keys (
"") are ignored across the board: setters onEntry,NewCtx().Set, andWithCtx(...).Any/Strbecome no-ops. - Empty string values:
NewCtx().Set(key, "")is ignored (not stored in the context).WithCtx(...).Str(key, "")is ignored (not stored in the context).Entry.Str(key, "")is allowed and logs an empty string value.
- Errors:
Entry.Err(err)logs under the default key"error".Entry.Err("customKey", err)logs under a custom key.
- No context processing happens unless you call
Entry.Ctx(ctx). - Context fields are imported into the log entry only when explicitly requested via
Entry.Ctx(ctx). WithCtx(ctx)avoids copying typed fields unless you actually add/modify fields and callContext().- If
WithCtx(ctx)does not add fields,Context()returns the originalctx(no extracontext.WithValue). - If the provided
ctxhas nologfields,Entry.Ctx(ctx)short-circuits without extra merge allocations.
Run:
go test -v -coverCurrent coverage: ~52% (52.2% of statements at the time of writing).
Unit tests currently cover:
- Default config behavior + time format override
- Level filtering + TRACE level rendering
- JSON output rules:
Entry.JSON,Any([]byte)auto-detect, invalid JSON fallback, map encoding - Text output rules:
time | level | trace | msgheader + no trace duplication in fields - Context helpers:
NewCtx/WithCtx(deadlines + cumulative fields) - Entry helpers:
Action,Time,Number,Err
These benchmarks are meant for regression tracking and transparency, not as a claim of βbetter than Xβ. Different libraries have different defaults and semantics, so always benchmark with your own workload/config.
Run:
cd .poc/bench
go test -bench=. -run=^$ -benchtime=7s -benchmemExample output (Apple M3 Max, darwin/arm64):
goos: darwin
goarch: arm64
pkg: log-bench
cpu: Apple M3 Max
BenchmarkLog_Text-16 43445082 181.0 ns/op 0 B/op 0 allocs/op
BenchmarkLog_JSON-16 35682792 240.0 ns/op 0 B/op 0 allocs/op
BenchmarkLog_Text_WithCtx-16 34005211 249.3 ns/op 0 B/op 0 allocs/op
BenchmarkLog_JSON_WithCtx-16 25595169 329.8 ns/op 0 B/op 0 allocs/op
BenchmarkGlog_Text-16 56735336 148.2 ns/op 0 B/op 0 allocs/op
BenchmarkGlog_JSON-16 32962898 254.7 ns/op 32 B/op 1 allocs/op
BenchmarkZerolog_JSON-16 52502023 156.2 ns/op 0 B/op 0 allocs/op
BenchmarkZerolog_Text-16 2310800 3644 ns/op 2438 B/op 82 allocs/op
BenchmarkZap_JSON-16 24222016 353.1 ns/op 352 B/op 2 allocs/op
BenchmarkZap_Text-16 21044398 403.5 ns/op 385 B/op 4 allocs/op
BenchmarkLogrus_JSON-16 4701202 1793 ns/op 2286 B/op 35 allocs/op
BenchmarkLogrus_Text-16 6027985 1390 ns/op 1298 B/op 20 allocs/op
PASS
ok
Compared libraries:
quick/glog(glog): https://github.com/jeffotoni/quick/tree/main/glogzerolog: https://github.com/rs/zerologzap: https://github.com/uber-go/zaplogrus: https://github.com/sirupsen/logrus
π Chart 1 β Latency (ns/op)
Reading: lower is better.
Highlights:
- π₯ Glog Text (~148 ns/op)
- π₯ Zerolog JSON (~156 ns/op)
- π₯ Log Text (~181 ns/op)
- The cost of
WithCtxinlogshows up clearly, but remains competitive. - Zerolog Text and Logrus stand out dramatically in this chart (good for storytelling π).
π Chart 2 β Throughput (iterations)
Reading: higher is better.
Here we use the raw benchmark iteration counts, for example:
43445082β43.4Miterations
Highlights:
- Glog Text (~56.7M) and Zerolog JSON (~52.5M) lead.
- Log Text (~43.4M) is strong and consistent.
- Zerolog Text drops to ~2.3M β huge visual contrast.
- Logrus confirms low throughput.
FormatText emits one line per record using a fixed header and a k=v tail:
- Header:
time | level | trace | msg(the separator is configurable viaConfig.Separator) - Trace extraction: the
traceheader is extracted by scanning attributes for any of these keys:Config.TraceIDKey,traceId,trace_id(first match wins). - No duplication: the trace key is not repeated in the tail
k=v..., keeping the header stable and avoiding redundancy. - Tail fields: all other fields are rendered as
key=value; structured values (maps/slices/structs) are rendered as JSON when possible.
By default, the trace key is traceId. You can change it per logger/config:
log := log.New(log.Config{TraceIDKey: "X-Trace-ID"})
log.Info().
TraceID("abc123").
Msg("x").
Send() // writes field "X-Trace-ID":"abc123"For contexts, you can also change the key on the builders:
ctx, cancel := log.NewCtx().
TraceKey("X-Trace-ID").
TraceID("abc123").
Build()
defer cancel()
ctx = log.WithCtx(ctx).
TraceKey("X-Trace-ID").
TraceID("abc123").
Context()In FormatText, the header field trace is extracted from the configured TraceIDKey
(also accepts traceId, trace_id). The trace field is not duplicated as k=v in the tail.
log.Info().
JSON("payload", []byte(`{"a":1}`)).
Msg("x").
Send()log.Info().
Any("payload", []byte(`{"a":1}`)).
Msg("x").
Send()Rules:
- if
json.Valid(trimmedBytes)=> embeds as JSON object/array - else if UTF-8 => stores as string
- else => stores as base64 string
This guarantees the final log output is always valid JSON (in FormatJSON).
type Config struct {
Format log.Format // json|text|slog
Writer io.Writer // default: os.Stdout
TimeFormat string // default: RFC3339
Level log.Level // default: INFO
Separator string // default: " | " (text) or " " (others)
ServiceName string // adds "service" to every entry
TraceIDKey string // default: "traceId"
}// JSON to stdout (default format is json if omitted).
log := log.New(log.Config{Format: log.FormatJSON})
log.Info().
Str("component", "api").
Msg("ready").
Send()// Human-friendly text format with a custom separator.
log := log.New(log.Config{
Format: log.FormatText,
Separator: " | ",
})
log.Info().
TraceID("abc123").
Msg("request").
Send()// Configure trace key + service name.
log := log.New(log.Config{
ServiceName: "api",
TraceIDKey: "X-Trace-ID",
})
log.Info().
TraceID("abc123").
Msg("x").
Send() // writes "X-Trace-ID":"abc123"// Default error key ("error").
log.Error().
Err(errors.New("boom")).
Msg("request failed").
Send()// Convenience numeric logging (without choosing Int/Float methods).
log.Info().
Number("status", 200).
Number("bytes", int64(1234)).
Number("latency_ms", 12.3).
Msg("request").
Send()// Context defaults + per-entry overrides (last-write-wins).
ctx := log.WithCtx(context.Background()).
Str("role", "user").
Any("attempt", 1).
Context()
log.Info().
Ctx(ctx).
Str("role", "admin").
Msg("request").
Send()package main
import (
"context"
"errors"
"os"
"time"
"github.com/jeffotoni/log"
)
func main() {
log := log.New(log.Config{
Format: log.FormatText,
Writer: os.Stdout,
TimeFormat: log.LayoutISO8601Nano,
Level: log.DEBUG,
Separator: " | ",
ServiceName: "api",
TraceIDKey: "X-Trace-ID",
})
ctx, cancel := log.NewCtx(context.Background()).
TraceKey("X-Trace-ID").
TraceID("abc123").
Set("X-User-ID", "user42").
Timeout(10 * time.Second).
Build()
defer cancel()
ctx = log.WithCtx(ctx).
Any("attempt", 3).
Bool("cached", true).
Any("meta", map[string]any{"a": 1, "b": "x"}).
Context()
log.Info().
Ctx(ctx).
Caller().
Component("auth").
Action("login").
Bool("success", true).
Int("status", 200).
Int64("bytes", 1234).
Float64("latency_ms", 12.3).
Duration("elapsed", 120*time.Millisecond).
Time("now", time.Now()).
Any("labels", map[string]string{"env": "dev"}).
JSON("payload", []byte(`{"ok":true}`)).
Err(errors.New("boom")).
Msg("request").
Send()
}The public API is intended to be stable, but it may still evolve with small breaking changes until v1.0.0.
If you like this project, give it a β star and feel free to open issues or PRs.
FormatJSONandFormatTextare implemented byloghandlers (they implementlog/slog.Handler).FormatSlogdelegates to the standard librarylog/slogtext handler.- Field ordering is not guaranteed (especially for fields imported from context maps).