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