commit 3667b92fabfb1622a1be0852341e9bb7e1049310 Author: Rene Nochebuena Date: Wed Mar 18 13:31:39 2026 -0600 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..50d9742 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d844420 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe1d27e --- /dev/null +++ b/README.md @@ -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 diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..bc754de --- /dev/null +++ b/compliance_test.go @@ -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{}) diff --git a/context.go b/context.go new file mode 100644 index 0000000..50d2a50 --- /dev/null +++ b/context.go @@ -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) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..d90c9eb --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-slog-stdlib-backend.md b/docs/adr/ADR-001-slog-stdlib-backend.md new file mode 100644 index 0000000..bd66123 --- /dev/null +++ b/docs/adr/ADR-001-slog-stdlib-backend.md @@ -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. diff --git a/docs/adr/ADR-002-requestid-context-ownership.md b/docs/adr/ADR-002-requestid-context-ownership.md new file mode 100644 index 0000000..16f4961 --- /dev/null +++ b/docs/adr/ADR-002-requestid-context-ownership.md @@ -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. diff --git a/docs/adr/ADR-003-exported-logger-interface.md b/docs/adr/ADR-003-exported-logger-interface.md new file mode 100644 index 0000000..99f868b --- /dev/null +++ b/docs/adr/ADR-003-exported-logger-interface.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8a7d60 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.nochebuena.dev/go/logz + +go 1.25 diff --git a/logz.go b/logz.go new file mode 100644 index 0000000..9204e3a --- /dev/null +++ b/logz.go @@ -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 +} diff --git a/logz_test.go b/logz_test.go new file mode 100644 index 0000000..6faa0ff --- /dev/null +++ b/logz_test.go @@ -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) + } +}