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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal 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
38
.gitignore
vendored
Normal 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
30
CHANGELOG.md
Normal 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
115
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
134
README.md
Normal 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
7
compliance_test.go
Normal 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
43
context.go
Normal 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
26
doc.go
Normal 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
|
||||
39
docs/adr/ADR-001-slog-stdlib-backend.md
Normal file
39
docs/adr/ADR-001-slog-stdlib-backend.md
Normal 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.
|
||||
47
docs/adr/ADR-002-requestid-context-ownership.md
Normal file
47
docs/adr/ADR-002-requestid-context-ownership.md
Normal 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.
|
||||
55
docs/adr/ADR-003-exported-logger-interface.md
Normal file
55
docs/adr/ADR-003-exported-logger-interface.md
Normal 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.
|
||||
148
logz.go
Normal file
148
logz.go
Normal 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
288
logz_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user