zlog

English | 简体中文
An out-of-the-box, structured logging component for Go backend services. Built on Uber zap as its high-performance core, zlog adds an engineering-friendly layer on top: a type-safe Field-style API, dual global-singleton / standalone-instance modes, a self-contained lightweight file rotator, level-based file splitting, context-based trace propagation, and an optional gin middleware.
Features
- Type-safe Field-style API:
zlog.Info("msg", zlog.String("k", "v"), zlog.Int("n", 1)) — no format strings, no sentinels, no reflection boxing.
- zap fully hidden: zero zap types exposed to callers, so the core can be swapped out later without breaking your code.
- Dual modes: global singleton (
Init + package-level functions) fits most services; New standalone instances suit multi-module isolation.
- Self-contained lightweight rotation: size + time (hourly/daily/weekly/monthly) rotation combined, write-triggered with no background goroutine, supporting retention by count/age and gzip compression.
- Level-based file splitting:
DEBUG/INFO go to app.log, WARN and above go to app.log.wf, making alert collection easy.
- Context trace propagation: once
trace_id / request_id are injected into the context, they appear automatically on every log line.
- Runtime level adjustment:
SetLevel is thread-safe and takes effect without a restart.
- gin middleware (optional subpackage): standalone access log + panic recovery with logging; the core library has zero gin dependency.
- Well-defined FATAL semantics: write → Sync →
os.Exit(1), with no log loss before exit.
Installation
go get github.com/leeyzero/zlog
Requires Go 1.22 or later (see go.mod).
Quick Start
Initialize the global logger once at process startup, then call the package-level functions from anywhere:
package main
import "github.com/leeyzero/zlog"
func main() {
if err := zlog.Init(zlog.DefaultConfig(), zlog.WithConsole(true)); err != nil {
panic(err)
}
defer zlog.Close()
zlog.Info("service started",
zlog.String("version", "v1.0.0"),
zlog.Int("port", 8080),
)
}
The default output is JSON, for example:
{"level":"INFO","time":"2026-07-22T11:04:05+08:00","caller":"main/main.go:12","msg":"service started","version":"v1.0.0","port":8080}
Core Usage
Field-style structured logging
Every field constructor returns a zlog.Field — type-safe and reflection-free:
zlog.String("user", "alice")
zlog.Int("uid", 1001)
zlog.Int64("order_id", 202607220001)
zlog.Float64("amount", 99.9)
zlog.Bool("admin", false)
zlog.Duration("cost", time.Millisecond*320)
zlog.Time("ts", time.Now())
zlog.Err(err) // key is fixed as "error"
zlog.Any("payload", anyValue) // fallback, uses reflection
Global singleton vs. standalone instance
// Mode 1: global singleton (recommended for most services)
zlog.Init(cfg)
zlog.Info("...")
// Mode 2: standalone instance (multi-module isolation / different outputs)
log, err := zlog.New(cfg)
defer log.Close()
log.Info("...")
When Init has not been called, the package-level functions fall back to a default logger writing to stderr, guaranteeing that calls never panic at any time.
Derived child loggers
With returns a derived logger carrying fixed fields; every subsequent log line automatically includes them:
reqLog := log.With(zlog.String("component", "order"))
reqLog.Info("created", zlog.Int64("id", 1))
// => ...,"component":"order","id":1
A derived instance shares the underlying file and level switch with its parent. Close should be called only once, on the root logger.
Context trace propagation
ctx = zlog.ContextWithTraceID(ctx, "trace-abc-123")
ctx = zlog.ContextWithRequestID(ctx, "req-0001")
// Recommended: derive once per request and reuse
reqLog := log.WithContext(ctx)
reqLog.Info("handling") // automatically carries trace_id / request_id
reqLog.Warn("slow")
Dynamic level adjustment
zlog.SetLevel(zlog.WarnLevel) // thread-safe, effective immediately
Configuration
Use DefaultConfig() to get a config with sensible defaults, then override via struct fields or functional options.
Config
| Field |
Type |
Default |
Description |
Level |
Level |
InfoLevel |
Minimum output level |
Encoding |
string |
"json" |
"json" or "console" |
Console |
bool |
false |
Whether to also write to stdout |
File |
FileConfig |
— |
File output; no file is written when Path is empty |
SplitLevel |
bool |
true |
Whether to split into .log / .log.wf |
Caller |
bool |
true |
Whether to print file:line |
TimeFormat |
string |
RFC3339 |
Time format |
ConsoleSeparator |
string |
"" (tab) |
Field separator for the console encoding; only effective when Encoding="console"; empty keeps zap's default tab |
FileConfig
| Field |
Type |
Default |
Description |
Path |
string |
— |
e.g. "logs/app.log"; the wf file is derived automatically as logs/app.log.wf |
MaxSize |
int |
100 |
Per-file size limit (MB); 0 means no size-based rotation |
MaxBackups |
int |
10 |
Number of archives to keep; 0 means unlimited |
MaxAge |
int |
30 |
Days to retain archives; 0 means unlimited |
Compress |
bool |
false |
gzip-compress old files |
Interval |
Interval |
None |
Time-based rotation granularity |
Options
zlog.New(zlog.DefaultConfig(),
zlog.WithLevel(zlog.DebugLevel),
zlog.WithEncoding("console"),
zlog.WithConsole(true),
zlog.WithFile(fc),
zlog.WithSplitLevel(false),
zlog.WithCaller(false),
zlog.WithTimeFormat(time.RFC3339Nano),
zlog.WithConsoleSeparator(" "), // console only, defaults to tab
)
Level strings can be parsed with ParseLevel (case-insensitive, handy for reading from a config file):
lv, err := zlog.ParseLevel("warn") // => zlog.WarnLevel
File Rotation and Level Splitting
Rotation is write-triggered: there is no background goroutine; the current file is archived only when a write crosses a size or time boundary.
- Size-based: archives when
MaxSize > 0 and the write would exceed the limit.
- Time-based:
Interval sets the granularity, with archive suffixes as follows:
| Interval |
Example archive suffix |
Description |
Hourly |
app.log.2026072211 |
Down to the hour |
Daily |
app.log.20260722 |
Down to the day |
Weekly |
app.log.20260720 |
The Monday date of that week |
Monthly |
app.log.202607 |
Down to the month |
When size forces multiple splits within the same period, a sequence number is appended: app.log.20260722.1, .2, and so on.
With SplitLevel enabled (the default), WARN and above are additionally written to the .wf file, following the same rotation rules (e.g. app.log.wf.20260722), so an alerting system can collect only the .wf files.
gin Middleware (optional)
gin-related capabilities live in the standalone subpackage middleware/ginlog; the core library itself does not depend on gin. Two middlewares are provided:
Middleware: writes a standalone access log (separate file, timestamp k=v text format, not wrapped in JSON), with fields such as trace_id, client_ip, method, path, status, cost_time (microseconds), plus optional business fields.
Recovery: catches panics, logs them to the application log, and returns 500.
access := ginlog.Middleware(ginlog.DefaultAccessConfig(),
ginlog.WithExtraFields(func(c *gin.Context) []ginlog.KV {
return []ginlog.KV{{Key: "app", Val: "demo"}}
}),
)
r := gin.New()
r.Use(ginlog.Recovery(log))
r.Use(access)
Example access log line:
2026/07/22 11:04:05:123456 trace_id=trace-abc client_ip=127.0.0.1 method=GET path=/api/user status=200 cost_time=1832 app=demo
The timestamp is precise to microseconds (colon-separated), cost_time is in microseconds, and values containing spaces are automatically quoted and escaped.
Fixed field keys under the JSON encoding: time, level, msg, caller (when Caller is enabled), stacktrace (for Error and above, as needed). Levels are uppercase (INFO, ERROR, ...).
Benchmarks with io.Discard as the sink and Caller enabled (go test -bench=. -benchmem, Xeon Gold 6271C):
| Scenario |
ns/op |
allocs/op |
Info (no fields) |
~1300 |
2 |
Info (5 fields) |
~2300 |
3 |
WithContext (derive per log line) |
~2900 |
10 |
Notes:
- The 2 allocations with no fields come from zap's encoding buffer and Caller stack capture, unrelated to this library's wrapper.
- The extra 1 allocation with fields is the Field → zap field conversion (the wrapper cost, quantified).
- The 10 allocations for
WithContext are the worst case of deriving a logger on every log line. Real usage should derive once per request with reqLog := log.WithContext(ctx) and reuse it, dropping back to 2–3 allocations.
- Ultra-high-throughput paths can disable
Caller (WithCaller(false)) to reduce overhead further.
Examples
The examples/ directory contains complete, independently runnable examples:
Run any example:
go run ./examples/basic
FAQ
Do the logging methods return an error or panic? No. Only New / Init return an error; Debug/Info/... never return an error and never panic.
Does FATAL lose logs? No. Fatal writes and Syncs first, then calls os.Exit(1).
Do derived loggers need to be closed separately? No. Instances derived via With / WithContext share the underlying file with the root; call Close once, on the root logger.
Why not use lumberjack? To keep size + time combined rotation, custom archive naming, and .wf splitting cohesive in one place, a self-contained lightweight rotator is used.
License
MIT