feat(logz): initial stable release v0.9.0

Structured logger backed by log/slog with request-context enrichment, extra-field context helpers, and duck-typed automatic error enrichment.

What's included:
- `Logger` interface with Debug / Info / Warn / Error / With / WithContext; `New(Options)` constructor writing to os.Stdout
- `WithRequestID` / `GetRequestID` and `WithField` / `WithFields` context helpers — package owns both context keys
- Automatic error_code and context-field enrichment in Logger.Error via duck-typed errorWithCode / errorWithContext interfaces (no xerrors import)

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-18 13:31:39 -06:00
commit 3667b92fab
15 changed files with 1020 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
"name": "Go",
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
"features": {
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
},
"forwardPorts": [],
"postCreateCommand": "go version",
"customizations": {
"vscode": {
"settings": {
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"explorer.compactFolders": false,
"explorer.showEmptyFolders": true
},
"extensions": [
"golang.go",
"eamodio.golang-postfix-completion",
"quicktype.quicktype",
"usernamehw.errorlens"
]
}
},
"remoteUser": "vscode"
}

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with go test -c
*.test
# Output of go build
*.out
# Dependency directory
vendor/
# Go workspace file
go.work
go.work.sum
# Environment files
.env
.env.*
# Editor / IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# VCS files
COMMIT.md
RELEASE.md

30
CHANGELOG.md Normal file
View File

@@ -0,0 +1,30 @@
# Changelog
All notable changes to this module will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2026-03-18
### Added
- `Logger` interface — `Debug(msg string, args ...any)`, `Info(msg string, args ...any)`, `Warn(msg string, args ...any)`, `Error(msg string, err error, args ...any)`, `With(args ...any) Logger`, `WithContext(ctx context.Context) Logger`
- `Options` struct — `Level slog.Level` (minimum log level; default `slog.LevelInfo`), `JSON bool` (JSON vs text output; default text), `StaticArgs []any` (key-value pairs attached to every record); zero value is valid
- `New(opts Options) Logger` — constructs a `Logger` backed by `log/slog`, writing to `os.Stdout`; returns the `Logger` interface, never the concrete type
- `WithRequestID(ctx context.Context, id string) context.Context` — stores a request correlation ID in the context under a private unexported key owned by this package
- `GetRequestID(ctx context.Context) string` — retrieves the correlation ID; returns `""` if absent or if ctx is nil
- `WithField(ctx context.Context, key string, value any) context.Context` — adds a single key-value logging field to the context
- `WithFields(ctx context.Context, fields map[string]any) context.Context` — merges multiple key-value fields into the context without overwriting existing fields
- `Logger.WithContext(ctx context.Context) Logger` — returns a child logger pre-enriched with `request_id` and any extra fields stored in the context via `WithRequestID` / `WithField` / `WithFields`; returns the same logger unchanged if ctx is nil or carries no relevant values
- `Logger.With(args ...any) Logger` — returns a child logger with the given key-value attributes permanently attached to every subsequent record
- Automatic `error_code` field enrichment in `Logger.Error` when `err` satisfies the private `errorWithCode` duck-type interface (`ErrorCode() string`)
- Automatic context-field enrichment in `Logger.Error` when `err` satisfies the private `errorWithContext` duck-type interface (`ErrorContext() map[string]any`)
### Design Notes
- `New` returns the `Logger` interface (not `*slogLogger`), and `With` likewise returns `Logger`; the concrete type is never exported, making the interface the only public surface and enabling safe local re-declaration in downstream libraries without importing this package.
- This package owns the context keys `ctxRequestIDKey{}` and `ctxExtraFieldsKey{}` as unexported struct types, preventing collisions; any module that needs to attach a request ID to logs imports only `logz`.
- The xerrors integration is zero-import: `Logger.Error` uses `errors.As` against two private interfaces (`errorWithCode`, `errorWithContext`) that `xerrors.Err` satisfies — no import between the two packages is required in either direction.
[0.9.0]: https://code.nochebuena.dev/go/logz/releases/tag/v0.9.0

115
CLAUDE.md Normal file
View File

@@ -0,0 +1,115 @@
# logz
Structured logger backed by log/slog with request-context enrichment and duck-typed error integration.
## Purpose
`logz` provides a stable `Logger` interface and a `New()` constructor. It wraps
`log/slog` (stdlib) so no external logging library is required. It owns the request
correlation ID context key and extra-field context key, providing helpers to attach
and retrieve them. When an error passed to `Logger.Error` implements `ErrorCode()`
or `ErrorContext()`, those fields are automatically appended to the log record —
without importing `xerrors`.
## Tier & Dependencies
**Tier:** 1
**Imports:** `context`, `errors`, `log/slog`, `os` (stdlib only)
**Must NOT import:** `xerrors`, `rbac`, `launcher`, or any other micro-lib module.
The xerrors bridge is achieved via private duck-typed interfaces (`errorWithCode`,
`errorWithContext`) — no import is needed.
## Key Design Decisions
- Wraps `log/slog` exclusively; no external logging library dependency. See
`docs/adr/ADR-001-slog-stdlib-backend.md`.
- `logz` owns `ctxRequestIDKey{}` and `ctxExtraFieldsKey{}`. Any package that needs
to attach a request ID to logs imports only `logz`. See
`docs/adr/ADR-002-requestid-context-ownership.md`.
- `Logger` is an exported interface; `New` returns it, not the concrete `*slogLogger`.
`With` returns `Logger`, not `*slogLogger`. See
`docs/adr/ADR-003-exported-logger-interface.md`.
- `Error(msg string, err error, args ...any)` treats `err` as a first-class
parameter, enabling automatic enrichment from duck-typed error interfaces.
## Patterns
**Creating a logger:**
```go
logger := logz.New(logz.Options{
Level: slog.LevelDebug,
JSON: true,
StaticArgs: []any{"service", "api", "env", "production"},
})
```
**Basic logging:**
```go
logger.Info("server started", "port", 8080)
logger.Warn("retrying", "attempt", 3)
logger.Error("request failed", err, "path", "/users")
// If err is *xerrors.Err, error_code and context fields are added automatically
```
**Child loggers:**
```go
reqLogger := logger.With("trace_id", traceID)
reqLogger.Info("handling request")
```
**Request context enrichment:**
```go
// In middleware:
ctx = logz.WithRequestID(ctx, requestID)
ctx = logz.WithField(ctx, "user_id", userID)
ctx = logz.WithFields(ctx, map[string]any{"tenant": tenantID, "region": "eu"})
// In handler (picks up request_id and extra fields automatically):
reqLogger := logger.WithContext(ctx)
reqLogger.Info("processing order")
```
**Retrieving the request ID (e.g. for response headers):**
```go
id := logz.GetRequestID(ctx)
```
**Local Logger interface in a library (ADR-001 pattern):**
```go
// In your library — does NOT import logz
type Logger interface {
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, err error, args ...any)
}
// logz.Logger satisfies this interface; pass logz.New(...) from the app layer
```
## What to Avoid
- Do not import `xerrors` from `logz`. The duck-type bridge (`errorWithCode`,
`errorWithContext`) keeps the two packages decoupled.
- Do not return `*slogLogger` from any exported function. The concrete type must
stay unexported so the interface contract is the only public surface.
- Do not write log output to `os.Stderr` or arbitrary `io.Writer`s. Output always
goes to `os.Stdout`; routing is the responsibility of the process supervisor.
- Do not use `slog.Attr` or `slog.Group` in the `Logger` interface. Keep the
variadic `key, value` convention for simplicity.
- Do not call `WithContext(nil)` — the method handles `nil` safely (returns the
same logger), but `WithRequestID` and `WithField` do not accept nil contexts.
- Do not add new methods to the `Logger` interface without a version bump. Any
addition is a breaking change for all callers that replicate the interface locally.
## Testing Notes
- `compliance_test.go` asserts at compile time that `New(Options{})` satisfies
`logz.Logger`, enforcing the full method set at every build.
- `logz_test.go` covers level filtering, JSON vs text mode, `With` chaining,
`WithContext` enrichment, and automatic error enrichment via duck-typed interfaces.
- No external test dependencies — run with plain `go test`.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 NOCHEBUENADEV
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

134
README.md Normal file
View File

@@ -0,0 +1,134 @@
# `logz`
> Structured logging backed by `log/slog` with automatic error enrichment.
**Module:** `code.nochebuena.dev/go/logz`
**Tier:** 1 — stdlib only (`log/slog`, `context`, `errors`, `os`)
**Go:** 1.25+
**Dependencies:** none
---
## Overview
`logz` wraps [`log/slog`](https://pkg.go.dev/log/slog) behind a simple `Logger` interface. It adds two things on top of plain slog:
1. **Automatic error enrichment**`Error` inspects the error for `ErrorCode()` and `ErrorContext()` methods and appends the code and context fields to the log record automatically. This pairs with `xerrors.Err` without importing `xerrors`.
2. **Context propagation helpers**`WithRequestID`, `WithField`, `WithFields` store values in `context.Context`; `WithContext` creates a child logger pre-loaded with those values.
## Installation
```sh
go get code.nochebuena.dev/go/logz
```
## Quick start
```go
import (
"log/slog"
"code.nochebuena.dev/go/logz"
)
logger := logz.New(logz.Options{
Level: slog.LevelInfo,
JSON: true,
StaticArgs: []any{"service", "api"},
})
logger.Info("server started", "port", 8080)
logger.Error("request failed", err)
```
## Usage
### Creating a logger
```go
// Zero value: INFO level, text output, no static args.
logger := logz.New(logz.Options{})
// Production: JSON, custom level, static service tag.
logger := logz.New(logz.Options{
Level: slog.LevelInfo,
JSON: true,
StaticArgs: []any{"service", "payments", "env", "prod"},
})
```
The library does **not** read environment variables. Reading `LOG_LEVEL` or `LOG_JSON_OUTPUT` is the application's responsibility — pass the parsed values into `Options`.
### Logging
```go
logger.Debug("cache miss", "key", cacheKey)
logger.Info("user created", "user_id", id)
logger.Warn("slow query", "duration_ms", 520)
logger.Error("save failed", err, "table", "orders")
```
`Error` automatically enriches the log record when `err` satisfies the duck-type interfaces:
| Method | What it adds |
|--------|-------------|
| `ErrorCode() string` | `error_code` attribute |
| `ErrorContext() map[string]any` | all key-value pairs in the map |
### Child loggers
```go
// Attach fixed attrs to every record from this logger.
reqLogger := logger.With("request_id", id, "user_id", uid)
// Attach attrs stored in context.
reqLogger := logger.WithContext(ctx)
```
### Context helpers
```go
// Store values.
ctx = logz.WithRequestID(ctx, requestID)
ctx = logz.WithField(ctx, "user_id", userID)
ctx = logz.WithFields(ctx, map[string]any{"tenant": "acme", "region": "us-east"})
// Retrieve.
id := logz.GetRequestID(ctx)
// Build a child logger with all context values pre-attached.
reqLogger := logger.WithContext(ctx)
```
`WithFields` merges with any existing fields in the context — it does not overwrite them.
## Design decisions
**No singleton**`logz.New(opts)` returns a plain value. Each component that needs logging receives a `logz.Logger` via constructor injection. Tests can create isolated loggers without global state.
**`Error` replaces `LogError`** — enrichment is automatic and zero-overhead when the error is a plain `error`. Callers need only one method instead of two.
**`Fatal` removed** — calling `os.Exit(1)` inside a library is untestable and bypasses deferred cleanup. Callers log the error then decide how to exit:
```go
logger.Error("fatal startup failure", err)
os.Exit(1)
```
**No env-var reading** — libraries should not read environment variables. The application reads `LOG_LEVEL`/`LOG_JSON_OUTPUT` and passes parsed values into `Options`.
**Duck-typing bridge**`logz` defines private `errorWithCode` and `errorWithContext` interfaces. `xerrors.Err` satisfies both structurally — no import of `xerrors` is needed.
## Ecosystem
```
Tier 0: xerrors
↑ (duck-types — no direct import)
Tier 1: logz ← you are here
Tier 2: httpclient, httputil
Tier 4: httpmw, httpauth, httpserver
```
## License
MIT

7
compliance_test.go Normal file
View File

@@ -0,0 +1,7 @@
package logz_test
import "code.nochebuena.dev/go/logz"
// Verify New returns a Logger — the concrete slogLogger type satisfies the interface.
// If any method is removed or its signature changes, this file fails to compile.
var _ logz.Logger = logz.New(logz.Options{})

43
context.go Normal file
View File

@@ -0,0 +1,43 @@
package logz
import "context"
// ctxRequestIDKey is the context key for the request correlation ID.
type ctxRequestIDKey struct{}
// ctxExtraFieldsKey is the context key for arbitrary logging fields.
type ctxExtraFieldsKey struct{}
// WithRequestID adds a request correlation ID to the context.
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxRequestIDKey{}, id)
}
// GetRequestID retrieves the correlation ID from the context.
// Returns "" if not present or if ctx is nil.
func GetRequestID(ctx context.Context) string {
if ctx == nil {
return ""
}
id, _ := ctx.Value(ctxRequestIDKey{}).(string)
return id
}
// WithField adds a single key-value pair to the context for logging.
func WithField(ctx context.Context, key string, value any) context.Context {
return WithFields(ctx, map[string]any{key: value})
}
// WithFields adds multiple key-value pairs to the context for logging.
// Merges with any existing fields — does not overwrite the whole map.
func WithFields(ctx context.Context, fields map[string]any) context.Context {
existing, _ := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
newMap := make(map[string]any, len(existing)+len(fields))
for k, v := range existing {
newMap[k] = v
}
for k, v := range fields {
newMap[k] = v
}
return context.WithValue(ctx, ctxExtraFieldsKey{}, newMap)
}

26
doc.go Normal file
View File

@@ -0,0 +1,26 @@
// Package logz provides structured logging backed by [log/slog].
//
// Create a logger with [New]:
//
// logger := logz.New(logz.Options{
// Level: slog.LevelDebug,
// JSON: true,
// StaticArgs: []any{"service", "api", "env", "production"},
// })
//
// Log at any level:
//
// logger.Info("server started", "port", 8080)
// logger.Error("request failed", err, "path", "/users")
//
// Errors that implement ErrorCode() and ErrorContext() are automatically
// enriched — the error code and context fields are added to the log record
// without any extra method calls. This pairs naturally with xerrors.Err.
//
// Attach request context to a child logger:
//
// ctx = logz.WithRequestID(ctx, requestID)
// ctx = logz.WithField(ctx, "user_id", userID)
// reqLogger := logger.WithContext(ctx)
// reqLogger.Info("handling request")
package logz

View File

@@ -0,0 +1,39 @@
# ADR-001: log/slog as the Logging Backend
**Status:** Accepted
**Date:** 2026-03-18
## Context
Structured logging is a cross-cutting concern required by nearly every module in
the ecosystem. External logging libraries (zerolog, zap, logrus) add transitive
dependencies, pin dependency versions, and require every module that accepts a
logger to either import the concrete library or define an adapter interface. Go 1.21
shipped `log/slog` as a stdlib structured logging API, providing JSON and text
handlers, level filtering, and attribute chaining with no external dependencies.
## Decision
`logz` wraps `log/slog` exclusively. The concrete type `slogLogger` holds a
`*slog.Logger`. `New(opts Options) Logger` constructs either a JSON handler
(`slog.NewJSONHandler`) or a text handler (`slog.NewTextHandler`) backed by
`os.Stdout`, controlled by `Options.JSON`.
`Options` exposes:
- `Level slog.Level` — minimum log level (zero value = `slog.LevelInfo`).
- `JSON bool` — JSON vs text output.
- `StaticArgs []any` — key-value pairs attached to every record via `slog.Logger.With`.
The `slog` dependency is internal to the `logz` package. Consumers depend only on
the `logz.Logger` interface and are not required to import `log/slog` at all.
## Consequences
- Zero external dependencies: `logz` stays at Tier 1 (stdlib only).
- The `slog` structured attribute system (`slog.String`, `slog.Int`, etc.) is
available internally but is not exposed through the `Logger` interface — callers
pass plain `key, value` pairs, which `slog` handles via its `any` variadic.
- Output always goes to `os.Stdout`. Log routing (to files, remote sinks) is the
responsibility of the process supervisor or log collector, not this package.
- If a future Go version modifies the `slog` API, only `logz` needs to be updated —
all consumers remain unaffected.

View File

@@ -0,0 +1,47 @@
# ADR-002: RequestID Context Ownership
**Status:** Accepted
**Date:** 2026-03-18
## Context
Global ADR-003 establishes that context helpers must live with their data owners.
The request correlation ID (`request_id`) is a logging concern — it is used
exclusively to enrich log records. Therefore its context key and helpers belong in
`logz`, not in an HTTP module or a generic `ctx` package.
If the key were defined in an HTTP middleware package, any non-HTTP component that
wanted to attach a correlation ID to logs would need to import an HTTP package. If
the key were defined in a separate context utility package, that package would
become an implicit dependency of both the HTTP layer and the logging layer with no
clear owner.
## Decision
Two unexported context key types are defined in `context.go`:
- `ctxRequestIDKey struct{}` — key for the correlation ID string.
- `ctxExtraFieldsKey struct{}` — key for a `map[string]any` of arbitrary extra log fields.
Four exported helpers manage these keys:
- `WithRequestID(ctx, id) context.Context` — stores the request ID.
- `GetRequestID(ctx) string` — retrieves it, returning `""` when absent or when `ctx` is nil.
- `WithField(ctx, key, value) context.Context` — adds one key-value pair to the extra fields map.
- `WithFields(ctx, fields) context.Context` — merges multiple pairs; does not overwrite
unrelated existing fields.
`Logger.WithContext(ctx) Logger` reads both keys and returns a child logger with the
found values pre-attached as attributes. The method returns the same logger unchanged
when neither key is present, avoiding an unnecessary allocation.
## Consequences
- Any package — HTTP middleware, gRPC interceptor, background worker — can attach a
request ID to the context by importing only `logz`. No HTTP package is needed.
- `WithFields` merges into a new map rather than mutating the existing one, so
middleware stages can add fields without affecting the context seen by upstream handlers.
- The unexported key types prevent key collisions: no external package can construct
or compare `ctxRequestIDKey{}` directly.
- `GetRequestID` is provided for diagnostic use (e.g. adding the request ID to an
error response header) without requiring the caller to call through the `Logger` API.

View File

@@ -0,0 +1,55 @@
# ADR-003: Exported Logger Interface
**Status:** Accepted
**Date:** 2026-03-18
## Context
Global ADR-001 establishes that app-facing modules define a local `Logger` interface
satisfied by `logz.Logger` so that libraries do not import `logz` directly. For this
duck-typing pattern to work, `logz` must export its `Logger` interface with a stable
method set that other packages can replicate locally.
If `Logger` were unexported, or if `New` returned `*slogLogger` (the concrete type),
consumers would need to import `logz` just to name the type in a parameter or field
declaration, coupling every module to the logging library.
## Decision
`Logger` is an exported interface in the `logz` package:
```go
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, err error, args ...any)
With(args ...any) Logger
WithContext(ctx context.Context) Logger
}
```
`New(opts Options) Logger` returns the interface, not `*slogLogger`. The concrete
type is unexported. `With` returns `Logger` (interface), not `*slogLogger` — this
is enforced by the return type in the interface definition and by the compliance test.
Modules that accept a logger define a local interface with the subset of methods they
use (typically `Info`, `Warn`, `Error`). `logz.Logger` satisfies any such local
interface because it is a superset.
The `Error` signature deliberately differs from `slog`'s: `Error(msg string, err
error, args ...any)`. The error is a first-class parameter rather than a key-value
attribute, enabling automatic enrichment from duck-typed interfaces (`ErrorCode`,
`ErrorContext`) without any extra caller code.
## Consequences
- Tier 1+ modules can accept a logger via a local `Logger` interface without
importing `logz`. They are decoupled from the logging backend.
- `launcher`, which imports `logz` directly (it is the bootstrap layer), uses
`logz.Logger` as the concrete type in its `New` signature.
- Adding methods to `logz.Logger` is a breaking change for all callers that replicate
the interface locally. Any new method must be evaluated carefully and accompanied
by a version bump.
- The `Error(msg, err, args...)` convention is not interchangeable with `slog`'s
`Error(msg, args...)`. Adapters must account for this signature difference.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module code.nochebuena.dev/go/logz
go 1.25

148
logz.go Normal file
View File

@@ -0,0 +1,148 @@
package logz
import (
"context"
"errors"
"log/slog"
"os"
)
// errorWithCode is satisfied by errors that expose a machine-readable error code.
// xerrors.Err satisfies this interface via its ErrorCode() method.
type errorWithCode interface {
ErrorCode() string
}
// errorWithContext is satisfied by errors that expose structured key-value context fields.
// xerrors.Err satisfies this interface via its ErrorContext() method.
type errorWithContext interface {
ErrorContext() map[string]any
}
// Options configures a Logger instance.
// The zero value is valid: INFO level, text output, no static args.
type Options struct {
// Level is the minimum log level. Default: slog.LevelInfo (zero value).
Level slog.Level
// JSON enables JSON output. Default: false (text output).
JSON bool
// StaticArgs are key-value pairs attached to every log record.
StaticArgs []any
}
// Logger is the interface for structured logging.
type Logger interface {
// Debug logs a message at DEBUG level.
Debug(msg string, args ...any)
// Info logs a message at INFO level.
Info(msg string, args ...any)
// Warn logs a message at WARN level.
Warn(msg string, args ...any)
// Error logs a message at ERROR level.
// If err satisfies errorWithCode or errorWithContext, the structured fields
// are automatically appended to the log record.
Error(msg string, err error, args ...any)
// With returns a new Logger with the given key-value attributes pre-attached.
With(args ...any) Logger
// WithContext returns a new Logger enriched with request_id and any extra
// fields stored in ctx via WithRequestID / WithField / WithFields.
WithContext(ctx context.Context) Logger
}
// slogLogger is the concrete implementation of Logger using slog.
type slogLogger struct {
logger *slog.Logger
}
// New returns a new Logger configured by opts.
func New(opts Options) Logger {
handlerOpts := &slog.HandlerOptions{
Level: opts.Level,
AddSource: false,
}
var handler slog.Handler
if opts.JSON {
handler = slog.NewJSONHandler(os.Stdout, handlerOpts)
} else {
handler = slog.NewTextHandler(os.Stdout, handlerOpts)
}
base := slog.New(handler)
if len(opts.StaticArgs) > 0 {
base = base.With(opts.StaticArgs...)
}
return &slogLogger{logger: base}
}
// Debug implements Logger.
func (l *slogLogger) Debug(msg string, args ...any) { l.logger.Debug(msg, args...) }
// Info implements Logger.
func (l *slogLogger) Info(msg string, args ...any) { l.logger.Info(msg, args...) }
// Warn implements Logger.
func (l *slogLogger) Warn(msg string, args ...any) { l.logger.Warn(msg, args...) }
// Error implements Logger.
func (l *slogLogger) Error(msg string, err error, args ...any) {
args = enrichErrorAttrs(err, args)
if err != nil {
args = append(args, slog.Any("error", err))
}
l.logger.Error(msg, args...)
}
// With implements Logger.
func (l *slogLogger) With(args ...any) Logger {
return &slogLogger{logger: l.logger.With(args...)}
}
// WithContext implements Logger.
func (l *slogLogger) WithContext(ctx context.Context) Logger {
if ctx == nil {
return l
}
newLogger := l.logger
modified := false
if id, ok := ctx.Value(ctxRequestIDKey{}).(string); ok && id != "" {
newLogger = newLogger.With(slog.String("request_id", id))
modified = true
}
if fields, ok := ctx.Value(ctxExtraFieldsKey{}).(map[string]any); ok {
for k, v := range fields {
newLogger = newLogger.With(k, v)
}
modified = true
}
if !modified {
return l
}
return &slogLogger{logger: newLogger}
}
// enrichErrorAttrs appends error_code and context fields from err to attrs
// when err satisfies the errorWithCode or errorWithContext duck-type interfaces.
// Returns attrs unchanged if err is nil or does not satisfy either interface.
func enrichErrorAttrs(err error, attrs []any) []any {
if err == nil {
return attrs
}
var ec errorWithCode
if errors.As(err, &ec) {
attrs = append(attrs, "error_code", ec.ErrorCode())
}
var ectx errorWithContext
if errors.As(err, &ectx) {
for k, v := range ectx.ErrorContext() {
attrs = append(attrs, k, v)
}
}
return attrs
}

288
logz_test.go Normal file
View File

@@ -0,0 +1,288 @@
package logz
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
)
// ---------------------------------------------------------------
// Duck-type test helpers
// ---------------------------------------------------------------
type errWithCode struct{ code string }
func (e *errWithCode) Error() string { return e.code }
func (e *errWithCode) ErrorCode() string { return e.code }
type errWithCtx struct{ fields map[string]any }
func (e *errWithCtx) Error() string { return "ctx-err" }
func (e *errWithCtx) ErrorContext() map[string]any { return e.fields }
type errFull struct {
code string
fields map[string]any
}
func (e *errFull) Error() string { return e.code }
func (e *errFull) ErrorCode() string { return e.code }
func (e *errFull) ErrorContext() map[string]any { return e.fields }
// ---------------------------------------------------------------
// Helper: logger that writes to a buffer for inspection
// ---------------------------------------------------------------
func newTestLogger(buf *bytes.Buffer, level slog.Level) *slogLogger {
handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: level})
return &slogLogger{logger: slog.New(handler)}
}
// ---------------------------------------------------------------
// Constructor tests
// ---------------------------------------------------------------
func TestNew_ZeroOptions(t *testing.T) {
logger := New(Options{})
if logger == nil {
t.Fatal("New(Options{}) returned nil")
}
// Zero value level is INFO — calling Info should not panic.
logger.Info("zero options test")
}
func TestNew_JSONFormat(t *testing.T) {
var buf bytes.Buffer
sl := &slogLogger{
logger: slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
}
sl.Info("json test", "key", "value")
var out map[string]any
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("expected JSON output, got: %s", buf.String())
}
if out["msg"] != "json test" {
t.Errorf("unexpected msg: %v", out["msg"])
}
}
func TestNew_StaticArgs(t *testing.T) {
var buf bytes.Buffer
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
base := slog.New(handler).With("service", "test-svc")
sl := &slogLogger{logger: base}
sl.Info("static args test")
if !strings.Contains(buf.String(), "test-svc") {
t.Errorf("static arg not found in output: %s", buf.String())
}
}
// ---------------------------------------------------------------
// Logger method tests
// ---------------------------------------------------------------
func TestLogger_Debug_Info_Warn(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelDebug)
// None of these should panic.
l.Debug("debug msg")
l.Info("info msg")
l.Warn("warn msg")
}
func TestLogger_Error_StandardError(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelError)
err := &errWithCode{code: "PLAIN"}
l.Error("std error", err)
if !strings.Contains(buf.String(), "std error") {
t.Errorf("message not found in output: %s", buf.String())
}
}
func TestLogger_Error_NilError(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelError)
// Should not panic.
l.Error("nil error test", nil)
}
func TestLogger_Error_DuckTypeCode(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelError)
err := &errWithCode{code: "NOT_FOUND"}
l.Error("duck code test", err)
if !strings.Contains(buf.String(), "NOT_FOUND") {
t.Errorf("error_code not found in output: %s", buf.String())
}
}
func TestLogger_Error_DuckTypeContext(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelError)
err := &errWithCtx{fields: map[string]any{"user_id": "abc"}}
l.Error("duck ctx test", err)
if !strings.Contains(buf.String(), "abc") {
t.Errorf("context field not found in output: %s", buf.String())
}
}
func TestLogger_Error_DuckTypeBoth(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelError)
err := &errFull{code: "INTERNAL", fields: map[string]any{"op": "db.query"}}
l.Error("full duck test", err)
output := buf.String()
if !strings.Contains(output, "INTERNAL") {
t.Errorf("error_code not found in output: %s", output)
}
if !strings.Contains(output, "db.query") {
t.Errorf("context field not found in output: %s", output)
}
}
func TestLogger_With(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelInfo)
child := l.With("component", "auth")
child.Info("with test")
if !strings.Contains(buf.String(), "auth") {
t.Errorf("With attr not found in output: %s", buf.String())
}
// Original logger must not have the attribute.
buf.Reset()
l.Info("original")
if strings.Contains(buf.String(), "auth") {
t.Errorf("With mutated original logger: %s", buf.String())
}
}
func TestLogger_WithContext_RequestID(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelInfo)
ctx := WithRequestID(context.Background(), "req-123")
child := l.WithContext(ctx)
child.Info("ctx request id test")
if !strings.Contains(buf.String(), "req-123") {
t.Errorf("request_id not found in output: %s", buf.String())
}
}
func TestLogger_WithContext_ExtraFields(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelInfo)
ctx := WithField(context.Background(), "tenant", "acme")
child := l.WithContext(ctx)
child.Info("ctx fields test")
if !strings.Contains(buf.String(), "acme") {
t.Errorf("context field not found in output: %s", buf.String())
}
}
func TestLogger_WithContext_EmptyContext(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelInfo)
child := l.WithContext(context.Background())
// Empty context — same pointer returned.
if child != Logger(l) {
t.Error("WithContext with empty context should return the same logger")
}
}
func TestLogger_WithContext_NilContext(t *testing.T) {
var buf bytes.Buffer
l := newTestLogger(&buf, slog.LevelInfo)
// Should not panic.
child := l.WithContext(nil)
if child != Logger(l) {
t.Error("WithContext with nil context should return the same logger")
}
}
// ---------------------------------------------------------------
// Context helper tests
// ---------------------------------------------------------------
func TestWithRequestID_GetRequestID(t *testing.T) {
ctx := WithRequestID(context.Background(), "abc-123")
if got := GetRequestID(ctx); got != "abc-123" {
t.Errorf("GetRequestID = %q, want %q", got, "abc-123")
}
}
func TestGetRequestID_NilContext(t *testing.T) {
if got := GetRequestID(nil); got != "" {
t.Errorf("GetRequestID(nil) = %q, want empty", got)
}
}
func TestWithField(t *testing.T) {
ctx := WithField(context.Background(), "key", "val")
fields, ok := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
if !ok {
t.Fatal("no fields in context")
}
if fields["key"] != "val" {
t.Errorf("field key = %v, want val", fields["key"])
}
}
func TestWithFields(t *testing.T) {
ctx := WithFields(context.Background(), map[string]any{"a": 1, "b": 2})
fields, ok := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
if !ok {
t.Fatal("no fields in context")
}
if fields["a"] != 1 || fields["b"] != 2 {
t.Errorf("fields = %v", fields)
}
}
func TestWithFields_MergePreservesExisting(t *testing.T) {
ctx := WithField(context.Background(), "first", "yes")
ctx = WithFields(ctx, map[string]any{"second": "yes"})
fields, _ := ctx.Value(ctxExtraFieldsKey{}).(map[string]any)
if fields["first"] != "yes" {
t.Error("first field was lost after second WithFields call")
}
if fields["second"] != "yes" {
t.Error("second field not present after WithFields call")
}
}
// ---------------------------------------------------------------
// enrichErrorAttrs tests
// ---------------------------------------------------------------
func TestEnrichErrorAttrs_NilError(t *testing.T) {
attrs := []any{"existing", "value"}
result := enrichErrorAttrs(nil, attrs)
if len(result) != len(attrs) {
t.Errorf("enrichErrorAttrs(nil) modified attrs: %v", result)
}
}