Compare commits
10 commits
dev
...
ax/review-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d5577f4d | ||
|
|
0c06f1498d | ||
| 5eaea6ada4 | |||
|
|
e4d86eb0fb | ||
| c656fed80b | |||
|
|
3423bac33f | ||
| 7c984b22dc | |||
|
|
318a948a33 | ||
| 6008ca5c3a | |||
|
|
426b164b75 |
9 changed files with 1229 additions and 428 deletions
61
SECURITY_ATTACK_VECTOR_MAPPING.md
Normal file
61
SECURITY_ATTACK_VECTOR_MAPPING.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Security Attack Vector Mapping
|
||||
|
||||
- `CLAUDE.md` reviewed.
|
||||
- `CODEX.md` was not present in this repository.
|
||||
- Scope: exported API entry points that accept caller-controlled data, plus direct OS/environment reads. Public field-based inputs (`Logger.Style*`, `Err.Op`, `Err.Msg`, `Err.Err`, `Err.Code`) are called out in the relevant rows because they are consumed by methods rather than setter functions.
|
||||
- Common sink: `(*Logger).log` in `log.go:169`-`242` writes the final log line with `fmt.Fprintf(output, "%s %s %s%s\n", ...)` at `log.go:242`.
|
||||
- Existing controls in the common sink: string values in `keyvals` are `%q`-escaped at `log.go:234`-`236`, exact-match redaction is applied with `slices.Contains(redactKeys, keyStr)` at `log.go:227`-`231`, and `Err`-derived `op` / `stack` context is auto-added at `log.go:178`-`211`.
|
||||
- Gaps in the common sink: `msg`, key names, `error` values, and other non-string values are formatted without sanitisation at `log.go:221`-`242`.
|
||||
|
||||
## Logger Configuration And Emission
|
||||
|
||||
| Function | File:Line | Input source | Flows into | Current validation | Potential attack vector |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `New(opts Options)` | `log.go:111` | Caller-controlled `Options.Level`, `Options.Output`, `Options.Rotation`, `Options.RedactKeys`; optional global `RotationWriterFactory` | Initial `Logger` state; `opts.Output` or `RotationWriterFactory(*opts.Rotation)` becomes `Logger.output`; `RedactKeys` copied into `Logger.redactKeys` and later checked during logging | Falls back to `os.Stderr` if output is `nil`; only uses rotation when `Rotation != nil`, `Filename != ""`, and `RotationWriterFactory != nil`; `RedactKeys` slice is cloned | Path-based write/exfiltration if untrusted `Rotation.Filename` reaches a permissive factory; arbitrary side effects or exfiltration through a caller-supplied writer; log suppression if an untrusted config sets `LevelQuiet`; redaction bypass if secret keys do not exactly match configured names |
|
||||
| `(*Logger).SetLevel(level Level)` | `log.go:136` | Caller-controlled log level | `Logger.level`, then `shouldLog()` in all log methods | None | Audit bypass or loss of forensic detail if attacker-controlled config lowers the level or uses unexpected numeric values |
|
||||
| `(*Logger).SetOutput(w io.Writer)` | `log.go:150` | Caller-controlled writer | `Logger.output`, then `fmt.Fprintf` in `log.go:242` | None | Log exfiltration, unexpected side effects in custom writers, or panic/DoS if a nil or broken writer is installed |
|
||||
| `(*Logger).SetRedactKeys(keys ...string)` | `log.go:157` | Caller-controlled key names | `Logger.redactKeys`, then `slices.Contains(redactKeys, keyStr)` during formatting | Slice clone only | Secret leakage when caller omits aliases, case variants, or confusable keys; CPU overhead grows linearly with large redact lists |
|
||||
| `(*Logger).Debug(msg string, keyvals ...any)` | `log.go:246` | Caller-controlled `msg`, `keyvals`, and mutable `Logger.StyleDebug` field | `StyleDebug("[DBG]")` -> `(*Logger).log` -> `fmt.Fprintf` | Level gate only; string `keyvals` values are quoted; exact-match redaction for configured keys; no nil check on `StyleDebug` | Log forging or parser confusion via unsanitised `msg`, key names, `error` strings, or non-string `fmt.Stringer` values; secret leakage in `msg` or non-redacted fields; nil style function can panic |
|
||||
| `(*Logger).Info(msg string, keyvals ...any)` | `log.go:253` | Caller-controlled `msg`, `keyvals`, and mutable `Logger.StyleInfo` field | `StyleInfo("[INF]")` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `Debug` | Same attack surface as `Debug`: log injection, secret leakage outside exact-key redaction, and nil-style panic/DoS |
|
||||
| `(*Logger).Warn(msg string, keyvals ...any)` | `log.go:260` | Caller-controlled `msg`, `keyvals`, and mutable `Logger.StyleWarn` field | `StyleWarn("[WRN]")` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `Debug` | Same attack surface as `Debug`: log injection, secret leakage outside exact-key redaction, and nil-style panic/DoS |
|
||||
| `(*Logger).Error(msg string, keyvals ...any)` | `log.go:267` | Caller-controlled `msg`, `keyvals`, and mutable `Logger.StyleError` field | `StyleError("[ERR]")` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `Debug` | Same attack surface as `Debug`; higher impact because error logs are more likely to be consumed by SIEMs and incident tooling |
|
||||
| `(*Logger).Security(msg string, keyvals ...any)` | `log.go:276` | Caller-controlled `msg`, `keyvals`, and mutable `Logger.StyleSecurity` field | `StyleSecurity("[SEC]")` -> `(*Logger).log` -> `fmt.Fprintf` | Uses `LevelError` threshold so security events still emit when normal info logging is disabled; otherwise same controls as `Debug` | Spoofed or injected security events if untrusted text reaches `msg` / `keyvals`; secret leakage in high-signal security logs; nil-style panic/DoS |
|
||||
| `Username()` | `log.go:284` | OS account lookup from `user.Current()` and environment variables `USER` / `USERNAME` | Returned username string to caller | Uses `user.Current()` first; falls back to `USER`, then `USERNAME`; no normalisation or escaping | Username spoofing in untrusted/containerised environments where env vars are attacker-controlled; downstream log/UI injection if callers print the returned value without escaping |
|
||||
| `SetDefault(l *Logger)` | `log.go:305` | Caller-controlled logger pointer and its mutable fields (`output`, `Style*`, redact config, level) | Global `defaultLogger`, then all package-level log functions plus `LogError`, `LogWarn`, and `Must` | None | Nil logger causes later panic/DoS; swapping the default logger can redirect logs, disable redaction, or install panicking style/output hooks globally |
|
||||
| `SetLevel(level Level)` | `log.go:310` | Caller-controlled log level | `defaultLogger.SetLevel(level)` | None beyond instance setter behaviour | Same as `(*Logger).SetLevel`, but process-global |
|
||||
| `SetRedactKeys(keys ...string)` | `log.go:315` | Caller-controlled redact keys | `defaultLogger.SetRedactKeys(keys...)` | None beyond instance setter behaviour | Same as `(*Logger).SetRedactKeys`, but process-global |
|
||||
| `Debug(msg string, keyvals ...any)` | `log.go:320` | Caller-controlled `msg` and `keyvals` routed through the global logger | `defaultLogger.Debug(...)` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `(*Logger).Debug`; additionally depends on `defaultLogger` not being nil | Same as `(*Logger).Debug`, plus global DoS if `defaultLogger` was set to nil |
|
||||
| `Info(msg string, keyvals ...any)` | `log.go:325` | Caller-controlled `msg` and `keyvals` routed through the global logger | `defaultLogger.Info(...)` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `(*Logger).Info`; additionally depends on `defaultLogger` not being nil | Same as `(*Logger).Info`, plus global DoS if `defaultLogger` was set to nil |
|
||||
| `Warn(msg string, keyvals ...any)` | `log.go:330` | Caller-controlled `msg` and `keyvals` routed through the global logger | `defaultLogger.Warn(...)` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `(*Logger).Warn`; additionally depends on `defaultLogger` not being nil | Same as `(*Logger).Warn`, plus global DoS if `defaultLogger` was set to nil |
|
||||
| `Error(msg string, keyvals ...any)` | `log.go:335` | Caller-controlled `msg` and `keyvals` routed through the global logger | `defaultLogger.Error(...)` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `(*Logger).Error`; additionally depends on `defaultLogger` not being nil | Same as `(*Logger).Error`, plus global DoS if `defaultLogger` was set to nil |
|
||||
| `Security(msg string, keyvals ...any)` | `log.go:340` | Caller-controlled `msg` and `keyvals` routed through the global logger | `defaultLogger.Security(...)` -> `(*Logger).log` -> `fmt.Fprintf` | Same controls as `(*Logger).Security`; additionally depends on `defaultLogger` not being nil | Same as `(*Logger).Security`, plus global DoS if `defaultLogger` was set to nil |
|
||||
|
||||
## Error Construction And Emission
|
||||
|
||||
| Function | File:Line | Input source | Flows into | Current validation | Potential attack vector |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `(*Err).Error()` | `errors.go:25` | Public `Err.Op`, `Err.Msg`, `Err.Err`, and `Err.Code` fields, including direct struct literal construction by callers | Returned error string via `fmt.Sprintf`; if the error is later logged as a non-string value, the text reaches `(*Logger).log` -> `fmt.Fprintf` | None | Log injection or response spoofing via attacker-controlled op/message/code text; secret disclosure via underlying error text; machine-readable code spoofing |
|
||||
| `E(op, msg string, err error)` | `errors.go:56` | Caller-controlled `op`, `msg`, `err` | New `Err` instance, then `(*Err).Error()` when rendered | None | Same as `(*Err).Error()`: attacker-controlled context and underlying error text can be surfaced in logs or responses |
|
||||
| `Wrap(err error, op, msg string)` | `errors.go:67` | Caller-controlled `err`, `op`, `msg` | New `Err` wrapper; preserves existing `Code` from wrapped `*Err` if present | Returns `nil` when `err == nil`; preserves code if wrapped error is an `*Err` | Untrusted wrapped errors can carry misleading `Code` / `Op` context forward; op/msg text is not sanitised before later logging or presentation |
|
||||
| `WrapCode(err error, code, op, msg string)` | `errors.go:86` | Caller-controlled `err`, `code`, `op`, `msg` | New `Err` with explicit code, then `(*Err).Error()` when rendered | Returns `nil` only when both `err == nil` and `code == ""` | Error-code spoofing; untrusted message/op text can be pushed into logs or external error responses |
|
||||
| `NewCode(code, msg string)` | `errors.go:99` | Caller-controlled `code`, `msg` | New `Err` with no underlying error | None | Same as `WrapCode` without an underlying cause: spoofed codes and unsanitised user-facing text |
|
||||
| `NewError(text string)` | `errors.go:119` | Caller-controlled text | `errors.New(text)` return value, then any downstream logging or response handling | None | Log / response injection or sensitive text disclosure when callers pass untrusted strings through as raw errors |
|
||||
| `Join(errs ...error)` | `errors.go:125` | Caller-controlled error list | `errors.Join(errs...)`, then downstream `Error()` / logging | Standard library behaviour only | Size amplification and multi-message disclosure if attacker-controlled errors are aggregated and later surfaced verbatim |
|
||||
| `LogError(err error, op, msg string)` | `errors.go:235` | Caller-controlled `err`, `op`, `msg`; global `defaultLogger` state | `Wrap(err, op, msg)` return value plus `defaultLogger.Error(msg, "op", op, "err", err)` -> `fmt.Fprintf` | Returns `nil` when `err == nil`; otherwise inherits logger controls (quoted string values, exact-key redaction) | Log injection through `msg`, `op`, or `err.Error()`; sensitive error disclosure; global DoS/exfiltration if `defaultLogger` was replaced or nil |
|
||||
| `LogWarn(err error, op, msg string)` | `errors.go:250` | Caller-controlled `err`, `op`, `msg`; global `defaultLogger` state | `Wrap(err, op, msg)` return value plus `defaultLogger.Warn(msg, "op", op, "err", err)` -> `fmt.Fprintf` | Returns `nil` when `err == nil`; otherwise inherits logger controls | Same as `LogError`, with the additional risk that warnings may be treated as non-critical and reviewed less carefully |
|
||||
| `Must(err error, op, msg string)` | `errors.go:265` | Caller-controlled `err`, `op`, `msg`; global `defaultLogger` state | `defaultLogger.Error(msg, "op", op, "err", err)` -> `fmt.Fprintf`, then `panic(Wrap(err, op, msg))` | Only executes when `err != nil`; otherwise same logger controls as `LogError` | Attacker-triggerable panic/DoS if external input can force the error path; same log injection and disclosure risks as `LogError` before the panic |
|
||||
|
||||
## Error Inspection Helpers
|
||||
|
||||
| Function | File:Line | Input source | Flows into | Current validation | Potential attack vector |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `(*Err).Unwrap()` | `errors.go:43` | Public `Err.Err` field | Returned underlying error | None | No direct sink in this package; risk comes from whatever the wrapped error does when callers continue processing it |
|
||||
| `Is(err, target error)` | `errors.go:107` | Caller-controlled `err`, `target` | `errors.Is(err, target)` return value | Standard library behaviour only | Minimal direct attack surface here; any custom `Is` semantics come from attacker-supplied error implementations |
|
||||
| `As(err error, target any)` | `errors.go:113` | Caller-controlled `err`, `target` | `errors.As(err, target)` return value | No wrapper-side validation of `target` | Invalid `target` values can panic through `errors.As`, creating an application-level DoS if misuse is reachable |
|
||||
| `Op(err error)` | `errors.go:133` | Caller-controlled error chain | Extracted `Err.Op` string | Type-check via `errors.As`; otherwise none | Spoofed operation names if callers trust attacker-controlled `*Err` values for audit, metrics, or access decisions |
|
||||
| `ErrCode(err error)` | `errors.go:143` | Caller-controlled error chain | Extracted `Err.Code` string | Type-check via `errors.As`; otherwise none | Spoofed machine-readable error codes if callers trust attacker-controlled wrapped errors |
|
||||
| `Message(err error)` | `errors.go:153` | Caller-controlled error chain | Extracted `Err.Msg` or `err.Error()` string | Returns `""` for `nil`; otherwise none | Unsanitised attacker-controlled message text can be re-used in logs, UIs, or API responses |
|
||||
| `Root(err error)` | `errors.go:166` | Caller-controlled error chain | Returned deepest unwrapped error | Returns `nil` for `nil`; otherwise none | Minimal direct sink, but can expose a sensitive root error that callers later surface verbatim |
|
||||
| `AllOps(err error)` | `errors.go:181` | Caller-controlled error chain | Iterator over all `Err.Op` values | Traverses `errors.Unwrap` chain; ignores non-`*Err` nodes | Long attacker-controlled wrap chains can increase CPU work; extracted op strings remain unsanitised |
|
||||
| `StackTrace(err error)` | `errors.go:198` | Caller-controlled error chain | `[]string` of operation names | None beyond `AllOps` traversal | Memory growth proportional to chain length; untrusted op strings may later be logged or displayed |
|
||||
| `FormatStackTrace(err error)` | `errors.go:207` | Caller-controlled error chain | Joined stack string via `strings.Join(ops, " -> ")` | Returns `""` when no ops are found; otherwise none | Log / UI injection if untrusted op names are later rendered without escaping; size amplification for deep chains |
|
||||
89
docs/api-contract.md
Normal file
89
docs/api-contract.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
title: API Contract
|
||||
description: Exported API surface for dappco.re/go/core/log
|
||||
---
|
||||
|
||||
# API Contract
|
||||
|
||||
This page enumerates the exported API surface of `dappco.re/go/core/log`.
|
||||
|
||||
`Test coverage` means there is an explicit test in `log_test.go` or
|
||||
`errors_test.go` that directly exercises the item. Indirect coverage through a
|
||||
different exported API is marked `no`.
|
||||
|
||||
## Types
|
||||
|
||||
| Name | Signature | Package Path | Description | Test Coverage |
|
||||
|------|-----------|--------------|-------------|---------------|
|
||||
| `Err` | `type Err struct { Op string; Msg string; Err error; Code string }` | `dappco.re/go/core/log` | Structured error with operation context, message, wrapped cause, and optional machine-readable code. | yes |
|
||||
| `Level` | `type Level int` | `dappco.re/go/core/log` | Logging verbosity enum ordered from quiet to debug. | yes |
|
||||
| `Logger` | `type Logger struct { StyleTimestamp func(string) string; StyleDebug func(string) string; StyleInfo func(string) string; StyleWarn func(string) string; StyleError func(string) string; StyleSecurity func(string) string; /* unexported fields */ }` | `dappco.re/go/core/log` | Concurrent-safe structured logger with overridable style hooks. | yes |
|
||||
| `Options` | `type Options struct { Level Level; Output io.Writer; Rotation *RotationOptions; RedactKeys []string }` | `dappco.re/go/core/log` | Logger construction options for level, destination, rotation, and redaction. | yes |
|
||||
| `RotationOptions` | `type RotationOptions struct { Filename string; MaxSize int; MaxAge int; MaxBackups int; Compress bool }` | `dappco.re/go/core/log` | Log rotation and retention policy passed to the rotation writer factory. | yes |
|
||||
|
||||
## Constants
|
||||
|
||||
| Name | Signature | Package Path | Description | Test Coverage |
|
||||
|------|-----------|--------------|-------------|---------------|
|
||||
| `LevelQuiet` | `const LevelQuiet Level = iota` | `dappco.re/go/core/log` | Suppresses all log output. | yes |
|
||||
| `LevelError` | `const LevelError Level` | `dappco.re/go/core/log` | Emits only error and security messages. | yes |
|
||||
| `LevelWarn` | `const LevelWarn Level` | `dappco.re/go/core/log` | Emits warnings, errors, and security messages. | yes |
|
||||
| `LevelInfo` | `const LevelInfo Level` | `dappco.re/go/core/log` | Emits informational, warning, error, and security messages. | yes |
|
||||
| `LevelDebug` | `const LevelDebug Level` | `dappco.re/go/core/log` | Emits all log levels, including debug details. | yes |
|
||||
|
||||
## Variable
|
||||
|
||||
| Name | Signature | Package Path | Description | Test Coverage |
|
||||
|------|-----------|--------------|-------------|---------------|
|
||||
| `RotationWriterFactory` | `var RotationWriterFactory func(RotationOptions) io.WriteCloser` | `dappco.re/go/core/log` | Optional injection point for creating rotating log writers. | yes |
|
||||
|
||||
## Methods
|
||||
|
||||
| Name | Signature | Package Path | Description | Test Coverage |
|
||||
|------|-----------|--------------|-------------|---------------|
|
||||
| `(*Err).Error` | `func (e *Err) Error() string` | `dappco.re/go/core/log` | Formats an `Err` into its public error string representation. | yes |
|
||||
| `(*Err).Unwrap` | `func (e *Err) Unwrap() error` | `dappco.re/go/core/log` | Returns the wrapped cause for `errors.Is` and `errors.As`. | yes |
|
||||
| `(Level).String` | `func (l Level) String() string` | `dappco.re/go/core/log` | Returns the canonical lowercase name for a log level. | yes |
|
||||
| `(*Logger).SetLevel` | `func (l *Logger) SetLevel(level Level)` | `dappco.re/go/core/log` | Updates the logger's active verbosity threshold. | yes |
|
||||
| `(*Logger).Level` | `func (l *Logger) Level() Level` | `dappco.re/go/core/log` | Returns the logger's current verbosity threshold. | yes |
|
||||
| `(*Logger).SetOutput` | `func (l *Logger) SetOutput(w io.Writer)` | `dappco.re/go/core/log` | Replaces the logger's output writer. | yes |
|
||||
| `(*Logger).SetRedactKeys` | `func (l *Logger) SetRedactKeys(keys ...string)` | `dappco.re/go/core/log` | Replaces the set of keys whose values are redacted in output. | yes |
|
||||
| `(*Logger).Debug` | `func (l *Logger) Debug(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Emits a debug log line when the logger level allows it. | yes |
|
||||
| `(*Logger).Info` | `func (l *Logger) Info(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Emits an informational log line when the logger level allows it. | yes |
|
||||
| `(*Logger).Warn` | `func (l *Logger) Warn(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Emits a warning log line when the logger level allows it. | yes |
|
||||
| `(*Logger).Error` | `func (l *Logger) Error(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Emits an error log line when the logger level allows it. | yes |
|
||||
| `(*Logger).Security` | `func (l *Logger) Security(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Emits a security log line using the security prefix at error visibility. | yes |
|
||||
|
||||
## Functions
|
||||
|
||||
| Name | Signature | Package Path | Description | Test Coverage |
|
||||
|------|-----------|--------------|-------------|---------------|
|
||||
| `New` | `func New(opts Options) *Logger` | `dappco.re/go/core/log` | Constructs a logger from the supplied options and defaults. | yes |
|
||||
| `Username` | `func Username() string` | `dappco.re/go/core/log` | Returns the current system username with environment fallbacks. | yes |
|
||||
| `Default` | `func Default() *Logger` | `dappco.re/go/core/log` | Returns the package-level default logger. | yes |
|
||||
| `SetDefault` | `func SetDefault(l *Logger)` | `dappco.re/go/core/log` | Replaces the package-level default logger. | yes |
|
||||
| `SetLevel` | `func SetLevel(level Level)` | `dappco.re/go/core/log` | Updates the package-level default logger's level. | yes |
|
||||
| `SetRedactKeys` | `func SetRedactKeys(keys ...string)` | `dappco.re/go/core/log` | Updates the package-level default logger's redaction keys. | yes |
|
||||
| `Debug` | `func Debug(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Proxies a debug log call to the default logger. | yes |
|
||||
| `Info` | `func Info(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Proxies an informational log call to the default logger. | yes |
|
||||
| `Warn` | `func Warn(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Proxies a warning log call to the default logger. | yes |
|
||||
| `Error` | `func Error(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Proxies an error log call to the default logger. | yes |
|
||||
| `Security` | `func Security(msg string, keyvals ...any)` | `dappco.re/go/core/log` | Proxies a security log call to the default logger. | yes |
|
||||
| `E` | `func E(op, msg string, err error) error` | `dappco.re/go/core/log` | Creates an `*Err` with operation context and an optional wrapped cause. | yes |
|
||||
| `Wrap` | `func Wrap(err error, op, msg string) error` | `dappco.re/go/core/log` | Wraps an error with operation context and preserves any existing code. | yes |
|
||||
| `WrapCode` | `func WrapCode(err error, code, op, msg string) error` | `dappco.re/go/core/log` | Wraps an error with operation context and an explicit machine-readable code. | yes |
|
||||
| `NewCode` | `func NewCode(code, msg string) error` | `dappco.re/go/core/log` | Creates a coded error without an underlying cause. | yes |
|
||||
| `Is` | `func Is(err, target error) bool` | `dappco.re/go/core/log` | Convenience wrapper around `errors.Is`. | yes |
|
||||
| `As` | `func As(err error, target any) bool` | `dappco.re/go/core/log` | Convenience wrapper around `errors.As`. | yes |
|
||||
| `NewError` | `func NewError(text string) error` | `dappco.re/go/core/log` | Convenience wrapper around `errors.New`. | yes |
|
||||
| `Join` | `func Join(errs ...error) error` | `dappco.re/go/core/log` | Convenience wrapper around `errors.Join`. | yes |
|
||||
| `Op` | `func Op(err error) string` | `dappco.re/go/core/log` | Extracts the outermost operation name from an `*Err`. | yes |
|
||||
| `ErrCode` | `func ErrCode(err error) string` | `dappco.re/go/core/log` | Extracts the machine-readable code from an `*Err`. | yes |
|
||||
| `Message` | `func Message(err error) string` | `dappco.re/go/core/log` | Returns an `*Err` message or falls back to `err.Error()`. | yes |
|
||||
| `Root` | `func Root(err error) error` | `dappco.re/go/core/log` | Returns the deepest wrapped cause in an error chain. | yes |
|
||||
| `AllOps` | `func AllOps(err error) iter.Seq[string]` | `dappco.re/go/core/log` | Returns an iterator over every non-empty operation in an error chain. | no |
|
||||
| `StackTrace` | `func StackTrace(err error) []string` | `dappco.re/go/core/log` | Collects the logical stack trace of operations from an error chain. | yes |
|
||||
| `FormatStackTrace` | `func FormatStackTrace(err error) string` | `dappco.re/go/core/log` | Formats the logical stack trace as a single `outer -> inner` string. | yes |
|
||||
| `LogError` | `func LogError(err error, op, msg string) error` | `dappco.re/go/core/log` | Logs at error level and returns the wrapped error. | yes |
|
||||
| `LogWarn` | `func LogWarn(err error, op, msg string) error` | `dappco.re/go/core/log` | Logs at warning level and returns the wrapped error. | yes |
|
||||
| `Must` | `func Must(err error, op, msg string)` | `dappco.re/go/core/log` | Logs and panics when given a non-nil error. | yes |
|
||||
74
docs/convention-drift-2026-03-23.md
Normal file
74
docs/convention-drift-2026-03-23.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Convention Drift Audit
|
||||
|
||||
Date: 2026-03-23
|
||||
|
||||
## Scope
|
||||
|
||||
- `CODEX.md` was not present anywhere under `/workspace` on 2026-03-23.
|
||||
- This audit used `CLAUDE.md` as the available convention source.
|
||||
- The `stdlib→core.*` bucket is interpreted here as explicit stdlib wording and non-`dappco.re/go/core/*` path references that still look pre-migration.
|
||||
|
||||
## Missing SPDX Headers
|
||||
|
||||
- `CLAUDE.md:1`
|
||||
- `errors.go:1`
|
||||
- `errors_test.go:1`
|
||||
- `log.go:1`
|
||||
- `log_test.go:1`
|
||||
- `docs/index.md:1`
|
||||
- `docs/development.md:1`
|
||||
- `docs/architecture.md:1`
|
||||
|
||||
## stdlib→core.* Drift
|
||||
|
||||
- `CLAUDE.md:33` still describes `Is`, `As`, and `Join` as `stdlib wrappers`.
|
||||
- `log.go:107` still references `core/go-io` rather than a `dappco.re/go/core/*` path.
|
||||
- `docs/index.md:8` still uses the old module path `forge.lthn.ai/core/go-log`.
|
||||
- `docs/index.md:18` still imports `forge.lthn.ai/core/go-log`.
|
||||
- `docs/index.md:81` still describes runtime dependencies as `Go standard library only`.
|
||||
- `docs/index.md:86` still references `core/go-io`.
|
||||
- `docs/index.md:92` still lists the module path as `forge.lthn.ai/core/go-log`.
|
||||
- `docs/index.md:95` still says `iter.Seq` comes from `the standard library`.
|
||||
- `docs/development.md:10` still says `iter.Seq` comes from `the standard library`.
|
||||
- `docs/development.md:126` still says `prefer the standard library`.
|
||||
- `docs/architecture.md:217` still references `core/go-io`.
|
||||
|
||||
## UK English Drift
|
||||
|
||||
- `errors.go:264` uses `Initialize()` in an example comment.
|
||||
- `log_test.go:179` uses `unauthorized access`.
|
||||
- `log_test.go:185` asserts on `unauthorized access`.
|
||||
- `errors_test.go:309` uses `initialization failed`.
|
||||
|
||||
## Missing Tests
|
||||
|
||||
- `errors.go:186` has no test for the `AllOps` early-stop path when the iterator consumer returns `false`.
|
||||
- `log.go:289` has no test coverage for `Username()` falling back to `USER`.
|
||||
- `log.go:292` has no test coverage for `Username()` falling back to `USERNAME` after `USER` is empty.
|
||||
|
||||
Coverage note: `go test -coverprofile=cover.out ./...` reports `97.7%` statement coverage; these are the only uncovered code paths in the current package.
|
||||
|
||||
## Missing Usage-Example Comments
|
||||
|
||||
Interpretation: public entry points without an inline `Example:` block in their doc comment, especially where peer APIs in `errors.go` already include examples.
|
||||
|
||||
- `log.go:71` `RotationOptions`
|
||||
- `log.go:94` `Options`
|
||||
- `log.go:108` `RotationWriterFactory`
|
||||
- `log.go:111` `New`
|
||||
- `log.go:157` `(*Logger).SetRedactKeys`
|
||||
- `log.go:276` `(*Logger).Security`
|
||||
- `log.go:284` `Username`
|
||||
- `log.go:300` `Default`
|
||||
- `log.go:305` `SetDefault`
|
||||
- `errors.go:107` `Is`
|
||||
- `errors.go:113` `As`
|
||||
- `errors.go:119` `NewError`
|
||||
- `errors.go:125` `Join`
|
||||
- `errors.go:133` `Op`
|
||||
- `errors.go:143` `ErrCode`
|
||||
- `errors.go:153` `Message`
|
||||
- `errors.go:166` `Root`
|
||||
- `errors.go:181` `AllOps`
|
||||
- `errors.go:198` `StackTrace`
|
||||
- `errors.go:207` `FormatStackTrace`
|
||||
64
errors.go
64
errors.go
|
|
@ -6,10 +6,9 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Err represents a structured error with operational context.
|
||||
|
|
@ -29,14 +28,14 @@ func (e *Err) Error() string {
|
|||
}
|
||||
if e.Err != nil {
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
|
||||
return core.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
|
||||
return core.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
|
||||
}
|
||||
if e.Code != "" {
|
||||
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
|
||||
return core.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s%s", prefix, e.Msg)
|
||||
return core.Sprintf("%s%s", prefix, e.Msg)
|
||||
}
|
||||
|
||||
// Unwrap returns the underlying error for use with errors.Is and errors.As.
|
||||
|
|
@ -69,9 +68,9 @@ func Wrap(err error, op, msg string) error {
|
|||
return nil
|
||||
}
|
||||
// Preserve Code from wrapped *Err
|
||||
var logErr *Err
|
||||
if As(err, &logErr) && logErr.Code != "" {
|
||||
return &Err{Op: op, Msg: msg, Err: err, Code: logErr.Code}
|
||||
var typedError *Err
|
||||
if As(err, &typedError) && typedError.Code != "" {
|
||||
return &Err{Op: op, Msg: msg, Err: err, Code: typedError.Code}
|
||||
}
|
||||
return &Err{Op: op, Msg: msg, Err: err}
|
||||
}
|
||||
|
|
@ -103,25 +102,30 @@ func NewCode(code, msg string) error {
|
|||
// --- Standard Library Wrappers ---
|
||||
|
||||
// Is reports whether any error in err's tree matches target.
|
||||
// Wrapper around errors.Is for convenience.
|
||||
//
|
||||
// if log.Is(err, ErrNotFound) { /* handle */ }
|
||||
func Is(err, target error) bool {
|
||||
return errors.Is(err, target)
|
||||
}
|
||||
|
||||
// As finds the first error in err's tree that matches target.
|
||||
// Wrapper around errors.As for convenience.
|
||||
//
|
||||
// var typedError *log.Err
|
||||
// if log.As(err, &typedError) { code := typedError.Code }
|
||||
func As(err error, target any) bool {
|
||||
return errors.As(err, target)
|
||||
}
|
||||
|
||||
// NewError creates a simple error with the given text.
|
||||
// Wrapper around errors.New for convenience.
|
||||
//
|
||||
// var ErrNotFound = log.NewError("not found")
|
||||
func NewError(text string) error {
|
||||
return errors.New(text)
|
||||
}
|
||||
|
||||
// Join combines multiple errors into one.
|
||||
// Wrapper around errors.Join for convenience.
|
||||
//
|
||||
// return log.Join(validationErr, permissionErr)
|
||||
func Join(errs ...error) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
|
@ -130,39 +134,47 @@ func Join(errs ...error) error {
|
|||
|
||||
// Op extracts the operation name from an error.
|
||||
// Returns empty string if the error is not an *Err.
|
||||
//
|
||||
// op := log.Op(err) // e.g. "user.Save"
|
||||
func Op(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Op
|
||||
var typedError *Err
|
||||
if As(err, &typedError) {
|
||||
return typedError.Op
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ErrCode extracts the error code from an error.
|
||||
// Returns empty string if the error is not an *Err or has no code.
|
||||
//
|
||||
// code := log.ErrCode(err) // e.g. "VALIDATION_FAILED"
|
||||
func ErrCode(err error) string {
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Code
|
||||
var typedError *Err
|
||||
if As(err, &typedError) {
|
||||
return typedError.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Message extracts the message from an error.
|
||||
// Returns the error's Error() string if not an *Err.
|
||||
//
|
||||
// msg := log.Message(err) // "rate limited" (not the full "api.Call: rate limited: ...")
|
||||
func Message(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var e *Err
|
||||
if As(err, &e) {
|
||||
return e.Msg
|
||||
var typedError *Err
|
||||
if As(err, &typedError) {
|
||||
return typedError.Msg
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// Root returns the root cause of an error chain.
|
||||
// Unwraps until no more wrapped errors are found.
|
||||
//
|
||||
// cause := log.Root(err)
|
||||
func Root(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
|
@ -178,6 +190,8 @@ func Root(err error) error {
|
|||
|
||||
// AllOps returns an iterator over all operational contexts in the error chain.
|
||||
// It traverses the error tree using errors.Unwrap.
|
||||
//
|
||||
// for op := range log.AllOps(err) { /* "api.Call" → "db.Query" → ... */ }
|
||||
func AllOps(err error) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for err != nil {
|
||||
|
|
@ -195,6 +209,8 @@ func AllOps(err error) iter.Seq[string] {
|
|||
|
||||
// StackTrace returns the logical stack trace (chain of operations) from an error.
|
||||
// It returns an empty slice if no operational context is found.
|
||||
//
|
||||
// ops := log.StackTrace(err) // ["api.Call", "db.Query", "sql.Exec"]
|
||||
func StackTrace(err error) []string {
|
||||
var stack []string
|
||||
for op := range AllOps(err) {
|
||||
|
|
@ -204,6 +220,8 @@ func StackTrace(err error) []string {
|
|||
}
|
||||
|
||||
// FormatStackTrace returns a pretty-printed logical stack trace.
|
||||
//
|
||||
// trace := log.FormatStackTrace(err) // "api.Call -> db.Query -> sql.Exec"
|
||||
func FormatStackTrace(err error) string {
|
||||
var ops []string
|
||||
for op := range AllOps(err) {
|
||||
|
|
@ -212,7 +230,7 @@ func FormatStackTrace(err error) string {
|
|||
if len(ops) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(ops, " -> ")
|
||||
return core.Join(" -> ", ops...)
|
||||
}
|
||||
|
||||
// --- Combined Log-and-Return Helpers ---
|
||||
|
|
|
|||
462
errors_test.go
462
errors_test.go
|
|
@ -2,36 +2,34 @@ package log
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"dappco.re/go/core"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// --- Err Type Tests ---
|
||||
// --- Err type ---
|
||||
|
||||
func TestErr_Error_Good(t *testing.T) {
|
||||
// With underlying error
|
||||
func TestErrors_ErrError_Good(t *testing.T) {
|
||||
// Op + Msg + underlying error
|
||||
err := &Err{Op: "db.Query", Msg: "failed to query", Err: errors.New("connection refused")}
|
||||
assert.Equal(t, "db.Query: failed to query: connection refused", err.Error())
|
||||
|
||||
// With code
|
||||
err = &Err{Op: "api.Call", Msg: "request failed", Code: "TIMEOUT"}
|
||||
assert.Equal(t, "api.Call: request failed [TIMEOUT]", err.Error())
|
||||
|
||||
// With both underlying error and code
|
||||
err = &Err{Op: "user.Save", Msg: "save failed", Err: errors.New("duplicate key"), Code: "DUPLICATE"}
|
||||
assert.Equal(t, "user.Save: save failed [DUPLICATE]: duplicate key", err.Error())
|
||||
|
||||
// Just op and msg
|
||||
err = &Err{Op: "cache.Get", Msg: "miss"}
|
||||
assert.Equal(t, "cache.Get: miss", err.Error())
|
||||
}
|
||||
|
||||
func TestErr_Error_EmptyOp_Good(t *testing.T) {
|
||||
// No Op - should not have leading colon
|
||||
func TestErrors_ErrError_Bad(t *testing.T) {
|
||||
// Code included in message
|
||||
err := &Err{Op: "api.Call", Msg: "request failed", Code: "TIMEOUT"}
|
||||
assert.Equal(t, "api.Call: request failed [TIMEOUT]", err.Error())
|
||||
|
||||
// All three: Op, Msg, Code, and underlying error
|
||||
err = &Err{Op: "user.Save", Msg: "save failed", Err: errors.New("duplicate key"), Code: "DUPLICATE"}
|
||||
assert.Equal(t, "user.Save: save failed [DUPLICATE]: duplicate key", err.Error())
|
||||
}
|
||||
|
||||
func TestErrors_ErrError_Ugly(t *testing.T) {
|
||||
// No Op — no leading colon
|
||||
err := &Err{Msg: "just a message"}
|
||||
assert.Equal(t, "just a message", err.Error())
|
||||
|
||||
|
|
@ -39,12 +37,18 @@ func TestErr_Error_EmptyOp_Good(t *testing.T) {
|
|||
err = &Err{Msg: "error with code", Code: "ERR_CODE"}
|
||||
assert.Equal(t, "error with code [ERR_CODE]", err.Error())
|
||||
|
||||
// No Op with underlying error
|
||||
// No Op with underlying error only
|
||||
err = &Err{Msg: "wrapped", Err: errors.New("underlying")}
|
||||
assert.Equal(t, "wrapped: underlying", err.Error())
|
||||
|
||||
// Just op and msg, no error and no code
|
||||
err = &Err{Op: "cache.Get", Msg: "miss"}
|
||||
assert.Equal(t, "cache.Get: miss", err.Error())
|
||||
}
|
||||
|
||||
func TestErr_Unwrap_Good(t *testing.T) {
|
||||
// --- Err.Unwrap ---
|
||||
|
||||
func TestErrors_ErrUnwrap_Good(t *testing.T) {
|
||||
underlying := errors.New("underlying error")
|
||||
err := &Err{Op: "test", Msg: "wrapped", Err: underlying}
|
||||
|
||||
|
|
@ -52,28 +56,50 @@ func TestErr_Unwrap_Good(t *testing.T) {
|
|||
assert.True(t, errors.Is(err, underlying))
|
||||
}
|
||||
|
||||
// --- Error Creation Function Tests ---
|
||||
func TestErrors_ErrUnwrap_Bad(t *testing.T) {
|
||||
// No underlying error — Unwrap returns nil
|
||||
err := &Err{Op: "op", Msg: "no cause"}
|
||||
assert.Nil(t, errors.Unwrap(err))
|
||||
}
|
||||
|
||||
func TestE_Good(t *testing.T) {
|
||||
func TestErrors_ErrUnwrap_Ugly(t *testing.T) {
|
||||
// Deeply nested — errors.Is still traverses the chain
|
||||
root := errors.New("root")
|
||||
level1 := &Err{Op: "l1", Msg: "level1", Err: root}
|
||||
level2 := &Err{Op: "l2", Msg: "level2", Err: level1}
|
||||
assert.True(t, errors.Is(level2, root))
|
||||
}
|
||||
|
||||
// --- E ---
|
||||
|
||||
func TestErrors_E_Good(t *testing.T) {
|
||||
underlying := errors.New("base error")
|
||||
err := E("op.Name", "something failed", underlying)
|
||||
|
||||
assert.NotNil(t, err)
|
||||
var logErr *Err
|
||||
assert.True(t, errors.As(err, &logErr))
|
||||
assert.Equal(t, "op.Name", logErr.Op)
|
||||
assert.Equal(t, "something failed", logErr.Msg)
|
||||
assert.Equal(t, underlying, logErr.Err)
|
||||
var typedError *Err
|
||||
assert.True(t, errors.As(err, &typedError))
|
||||
assert.Equal(t, "op.Name", typedError.Op)
|
||||
assert.Equal(t, "something failed", typedError.Msg)
|
||||
assert.Equal(t, underlying, typedError.Err)
|
||||
}
|
||||
|
||||
func TestE_Good_NilError(t *testing.T) {
|
||||
// E creates an error even with nil underlying - useful for errors without causes
|
||||
func TestErrors_E_Bad(t *testing.T) {
|
||||
// E with nil underlying error still creates an error
|
||||
err := E("op.Name", "message", nil)
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "op.Name: message", err.Error())
|
||||
}
|
||||
|
||||
func TestWrap_Good(t *testing.T) {
|
||||
func TestErrors_E_Ugly(t *testing.T) {
|
||||
// E with empty op produces no leading colon
|
||||
err := E("", "bare message", nil)
|
||||
assert.Equal(t, "bare message", err.Error())
|
||||
}
|
||||
|
||||
// --- Wrap ---
|
||||
|
||||
func TestErrors_Wrap_Good(t *testing.T) {
|
||||
underlying := errors.New("base")
|
||||
err := Wrap(underlying, "handler.Process", "processing failed")
|
||||
|
||||
|
|
@ -83,11 +109,15 @@ func TestWrap_Good(t *testing.T) {
|
|||
assert.True(t, errors.Is(err, underlying))
|
||||
}
|
||||
|
||||
func TestWrap_PreservesCode_Good(t *testing.T) {
|
||||
// Create an error with a code
|
||||
inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed")
|
||||
func TestErrors_Wrap_Bad(t *testing.T) {
|
||||
// Wrap nil returns nil
|
||||
err := Wrap(nil, "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
// Wrap it - should preserve the code
|
||||
func TestErrors_Wrap_Ugly(t *testing.T) {
|
||||
// Wrap preserves Code from the inner *Err
|
||||
inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed")
|
||||
outer := Wrap(inner, "outer.Op", "outer context")
|
||||
|
||||
assert.NotNil(t, outer)
|
||||
|
|
@ -95,69 +125,135 @@ func TestWrap_PreservesCode_Good(t *testing.T) {
|
|||
assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]")
|
||||
}
|
||||
|
||||
func TestWrap_NilError_Good(t *testing.T) {
|
||||
err := Wrap(nil, "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
// --- WrapCode ---
|
||||
|
||||
func TestWrapCode_Good(t *testing.T) {
|
||||
func TestErrors_WrapCode_Good(t *testing.T) {
|
||||
underlying := errors.New("validation failed")
|
||||
err := WrapCode(underlying, "INVALID_INPUT", "api.Validate", "bad request")
|
||||
|
||||
assert.NotNil(t, err)
|
||||
var logErr *Err
|
||||
assert.True(t, errors.As(err, &logErr))
|
||||
assert.Equal(t, "INVALID_INPUT", logErr.Code)
|
||||
assert.Equal(t, "api.Validate", logErr.Op)
|
||||
var typedError *Err
|
||||
assert.True(t, errors.As(err, &typedError))
|
||||
assert.Equal(t, "INVALID_INPUT", typedError.Code)
|
||||
assert.Equal(t, "api.Validate", typedError.Op)
|
||||
assert.Contains(t, err.Error(), "[INVALID_INPUT]")
|
||||
}
|
||||
|
||||
func TestWrapCode_Good_NilError(t *testing.T) {
|
||||
// WrapCode with nil error but with code still creates an error
|
||||
func TestErrors_WrapCode_Bad(t *testing.T) {
|
||||
// nil error but with code still creates an error
|
||||
err := WrapCode(nil, "CODE", "op", "msg")
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "[CODE]")
|
||||
}
|
||||
|
||||
// Only returns nil when both error and code are empty
|
||||
err = WrapCode(nil, "", "op", "msg")
|
||||
func TestErrors_WrapCode_Ugly(t *testing.T) {
|
||||
// nil error AND empty code — only then returns nil
|
||||
err := WrapCode(nil, "", "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestNewCode_Good(t *testing.T) {
|
||||
// --- NewCode ---
|
||||
|
||||
func TestErrors_NewCode_Good(t *testing.T) {
|
||||
err := NewCode("NOT_FOUND", "resource not found")
|
||||
|
||||
var logErr *Err
|
||||
assert.True(t, errors.As(err, &logErr))
|
||||
assert.Equal(t, "NOT_FOUND", logErr.Code)
|
||||
assert.Equal(t, "resource not found", logErr.Msg)
|
||||
assert.Nil(t, logErr.Err)
|
||||
var typedError *Err
|
||||
assert.True(t, errors.As(err, &typedError))
|
||||
assert.Equal(t, "NOT_FOUND", typedError.Code)
|
||||
assert.Equal(t, "resource not found", typedError.Msg)
|
||||
assert.Nil(t, typedError.Err)
|
||||
}
|
||||
|
||||
// --- Standard Library Wrapper Tests ---
|
||||
func TestErrors_NewCode_Bad(t *testing.T) {
|
||||
// Empty code is preserved as-is
|
||||
err := NewCode("", "no code error")
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "no code error", err.Error())
|
||||
}
|
||||
|
||||
func TestIs_Good(t *testing.T) {
|
||||
func TestErrors_NewCode_Ugly(t *testing.T) {
|
||||
// NewCode result can be used as a sentinel value
|
||||
sentinel := NewCode("SENTINEL", "sentinel error")
|
||||
wrapped := Wrap(sentinel, "caller.Op", "something went wrong")
|
||||
assert.True(t, Is(wrapped, sentinel))
|
||||
}
|
||||
|
||||
// --- Is ---
|
||||
|
||||
func TestErrors_Is_Good(t *testing.T) {
|
||||
sentinel := errors.New("sentinel")
|
||||
wrapped := Wrap(sentinel, "test", "wrapped")
|
||||
|
||||
assert.True(t, Is(wrapped, sentinel))
|
||||
assert.False(t, Is(wrapped, errors.New("other")))
|
||||
}
|
||||
|
||||
func TestAs_Good(t *testing.T) {
|
||||
func TestErrors_Is_Bad(t *testing.T) {
|
||||
// Different errors are not equal
|
||||
assert.False(t, Is(errors.New("a"), errors.New("b")))
|
||||
}
|
||||
|
||||
func TestErrors_Is_Ugly(t *testing.T) {
|
||||
// nil target
|
||||
assert.False(t, Is(errors.New("something"), nil))
|
||||
// nil err
|
||||
assert.False(t, Is(nil, errors.New("something")))
|
||||
// both nil
|
||||
assert.True(t, Is(nil, nil))
|
||||
}
|
||||
|
||||
// --- As ---
|
||||
|
||||
func TestErrors_As_Good(t *testing.T) {
|
||||
err := E("test.Op", "message", errors.New("base"))
|
||||
|
||||
var logErr *Err
|
||||
assert.True(t, As(err, &logErr))
|
||||
assert.Equal(t, "test.Op", logErr.Op)
|
||||
var typedError *Err
|
||||
assert.True(t, As(err, &typedError))
|
||||
assert.Equal(t, "test.Op", typedError.Op)
|
||||
}
|
||||
|
||||
func TestNewError_Good(t *testing.T) {
|
||||
func TestErrors_As_Bad(t *testing.T) {
|
||||
// As returns false for non-matching types
|
||||
err := errors.New("plain")
|
||||
var typedError *Err
|
||||
assert.False(t, As(err, &typedError))
|
||||
}
|
||||
|
||||
func TestErrors_As_Ugly(t *testing.T) {
|
||||
// As traverses the chain to find *Err
|
||||
plain := errors.New("base")
|
||||
wrapped := Wrap(plain, "op", "msg")
|
||||
doubleWrapped := fmt.Errorf("fmt wrapper: %w", wrapped)
|
||||
|
||||
var typedError *Err
|
||||
assert.True(t, As(doubleWrapped, &typedError))
|
||||
assert.Equal(t, "op", typedError.Op)
|
||||
}
|
||||
|
||||
// --- NewError ---
|
||||
|
||||
func TestErrors_NewError_Good(t *testing.T) {
|
||||
err := NewError("simple error")
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "simple error", err.Error())
|
||||
}
|
||||
|
||||
func TestJoin_Good(t *testing.T) {
|
||||
func TestErrors_NewError_Bad(t *testing.T) {
|
||||
// Two NewError calls with same text are distinct values
|
||||
a := NewError("same text")
|
||||
b := NewError("same text")
|
||||
assert.False(t, Is(a, b), "two NewError values with same text must not match via Is")
|
||||
}
|
||||
|
||||
func TestErrors_NewError_Ugly(t *testing.T) {
|
||||
// Empty text produces an error with empty message
|
||||
err := NewError("")
|
||||
assert.NotNil(t, err)
|
||||
assert.Equal(t, "", err.Error())
|
||||
}
|
||||
|
||||
// --- Join ---
|
||||
|
||||
func TestErrors_Join_Good(t *testing.T) {
|
||||
err1 := errors.New("error 1")
|
||||
err2 := errors.New("error 2")
|
||||
joined := Join(err1, err2)
|
||||
|
|
@ -166,52 +262,78 @@ func TestJoin_Good(t *testing.T) {
|
|||
assert.True(t, errors.Is(joined, err2))
|
||||
}
|
||||
|
||||
// --- Helper Function Tests ---
|
||||
func TestErrors_Join_Bad(t *testing.T) {
|
||||
// All nil — returns nil
|
||||
assert.Nil(t, Join(nil, nil))
|
||||
}
|
||||
|
||||
func TestOp_Good(t *testing.T) {
|
||||
func TestErrors_Join_Ugly(t *testing.T) {
|
||||
// Mix of nil and non-nil — non-nil are preserved
|
||||
err := errors.New("only error")
|
||||
joined := Join(nil, err, nil)
|
||||
assert.True(t, errors.Is(joined, err))
|
||||
}
|
||||
|
||||
// --- Op ---
|
||||
|
||||
func TestErrors_Op_Good(t *testing.T) {
|
||||
err := E("mypackage.MyFunc", "failed", errors.New("cause"))
|
||||
assert.Equal(t, "mypackage.MyFunc", Op(err))
|
||||
}
|
||||
|
||||
func TestOp_Good_NotLogError(t *testing.T) {
|
||||
func TestErrors_Op_Bad(t *testing.T) {
|
||||
// Plain error has no op
|
||||
err := errors.New("plain error")
|
||||
assert.Equal(t, "", Op(err))
|
||||
}
|
||||
|
||||
func TestErrCode_Good(t *testing.T) {
|
||||
func TestErrors_Op_Ugly(t *testing.T) {
|
||||
// Outer op is returned, not inner
|
||||
inner := E("inner.Op", "inner", nil)
|
||||
outer := Wrap(inner, "outer.Op", "outer")
|
||||
assert.Equal(t, "outer.Op", Op(outer))
|
||||
}
|
||||
|
||||
// --- ErrCode ---
|
||||
|
||||
func TestErrors_ErrCode_Good(t *testing.T) {
|
||||
err := WrapCode(errors.New("base"), "ERR_CODE", "op", "msg")
|
||||
assert.Equal(t, "ERR_CODE", ErrCode(err))
|
||||
}
|
||||
|
||||
func TestErrCode_Good_NoCode(t *testing.T) {
|
||||
func TestErrors_ErrCode_Bad(t *testing.T) {
|
||||
// No code on a plain *Err
|
||||
err := E("op", "msg", errors.New("base"))
|
||||
assert.Equal(t, "", ErrCode(err))
|
||||
}
|
||||
|
||||
func TestErrCode_Good_PlainError(t *testing.T) {
|
||||
err := errors.New("plain error")
|
||||
assert.Equal(t, "", ErrCode(err))
|
||||
}
|
||||
|
||||
func TestErrCode_Good_Nil(t *testing.T) {
|
||||
func TestErrors_ErrCode_Ugly(t *testing.T) {
|
||||
// Nil and plain errors both return empty string
|
||||
assert.Equal(t, "", ErrCode(nil))
|
||||
assert.Equal(t, "", ErrCode(errors.New("plain")))
|
||||
}
|
||||
|
||||
func TestMessage_Good(t *testing.T) {
|
||||
// --- Message ---
|
||||
|
||||
func TestErrors_Message_Good(t *testing.T) {
|
||||
err := E("op", "the message", errors.New("base"))
|
||||
assert.Equal(t, "the message", Message(err))
|
||||
}
|
||||
|
||||
func TestMessage_Good_PlainError(t *testing.T) {
|
||||
func TestErrors_Message_Bad(t *testing.T) {
|
||||
// Plain error — falls back to Error() string
|
||||
err := errors.New("plain message")
|
||||
assert.Equal(t, "plain message", Message(err))
|
||||
}
|
||||
|
||||
func TestMessage_Good_Nil(t *testing.T) {
|
||||
func TestErrors_Message_Ugly(t *testing.T) {
|
||||
// Nil returns empty string
|
||||
assert.Equal(t, "", Message(nil))
|
||||
}
|
||||
|
||||
func TestRoot_Good(t *testing.T) {
|
||||
// --- Root ---
|
||||
|
||||
func TestErrors_Root_Good(t *testing.T) {
|
||||
root := errors.New("root cause")
|
||||
level1 := Wrap(root, "level1", "wrapped once")
|
||||
level2 := Wrap(level1, "level2", "wrapped twice")
|
||||
|
|
@ -219,19 +341,88 @@ func TestRoot_Good(t *testing.T) {
|
|||
assert.Equal(t, root, Root(level2))
|
||||
}
|
||||
|
||||
func TestRoot_Good_SingleError(t *testing.T) {
|
||||
func TestErrors_Root_Bad(t *testing.T) {
|
||||
// Single unwrapped error returns itself
|
||||
err := errors.New("single")
|
||||
assert.Equal(t, err, Root(err))
|
||||
}
|
||||
|
||||
func TestRoot_Good_Nil(t *testing.T) {
|
||||
func TestErrors_Root_Ugly(t *testing.T) {
|
||||
// Nil returns nil
|
||||
assert.Nil(t, Root(nil))
|
||||
}
|
||||
|
||||
// --- Log-and-Return Helper Tests ---
|
||||
// --- StackTrace / FormatStackTrace ---
|
||||
|
||||
func TestLogError_Good(t *testing.T) {
|
||||
// Capture log output
|
||||
func TestErrors_StackTrace_Good(t *testing.T) {
|
||||
err := E("op1", "msg1", nil)
|
||||
err = Wrap(err, "op2", "msg2")
|
||||
err = Wrap(err, "op3", "msg3")
|
||||
|
||||
stack := StackTrace(err)
|
||||
assert.Equal(t, []string{"op3", "op2", "op1"}, stack)
|
||||
|
||||
formatted := FormatStackTrace(err)
|
||||
assert.Equal(t, "op3 -> op2 -> op1", formatted)
|
||||
}
|
||||
|
||||
func TestErrors_StackTrace_Bad(t *testing.T) {
|
||||
// Plain error has no ops in the stack
|
||||
err := errors.New("plain error")
|
||||
assert.Empty(t, StackTrace(err))
|
||||
assert.Empty(t, FormatStackTrace(err))
|
||||
}
|
||||
|
||||
func TestErrors_StackTrace_Ugly(t *testing.T) {
|
||||
// Nil and *Err with no Op both yield empty stack
|
||||
assert.Empty(t, StackTrace(nil))
|
||||
assert.Empty(t, FormatStackTrace(nil))
|
||||
assert.Empty(t, StackTrace(&Err{Msg: "no op"}))
|
||||
|
||||
// Mixed chain: fmt.Errorf wrapper in the middle — ops on both sides still appear
|
||||
inner := E("inner", "msg", nil)
|
||||
wrapped := fmt.Errorf("fmt wrapper: %w", inner)
|
||||
outer := Wrap(wrapped, "outer", "msg")
|
||||
|
||||
stack := StackTrace(outer)
|
||||
assert.Equal(t, []string{"outer", "inner"}, stack)
|
||||
}
|
||||
|
||||
// --- AllOps ---
|
||||
|
||||
func TestErrors_AllOps_Good(t *testing.T) {
|
||||
err := E("op1", "msg1", nil)
|
||||
err = Wrap(err, "op2", "msg2")
|
||||
|
||||
var ops []string
|
||||
for op := range AllOps(err) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
assert.Equal(t, []string{"op2", "op1"}, ops)
|
||||
}
|
||||
|
||||
func TestErrors_AllOps_Bad(t *testing.T) {
|
||||
// Plain error yields no ops
|
||||
err := errors.New("plain")
|
||||
var ops []string
|
||||
for op := range AllOps(err) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
assert.Empty(t, ops)
|
||||
}
|
||||
|
||||
func TestErrors_AllOps_Ugly(t *testing.T) {
|
||||
// Nil error — iterator yields nothing, no panic
|
||||
var ops []string
|
||||
for op := range AllOps(nil) {
|
||||
ops = append(ops, op)
|
||||
}
|
||||
assert.Empty(t, ops)
|
||||
}
|
||||
|
||||
// --- LogError ---
|
||||
|
||||
func TestErrors_LogError_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
|
|
@ -240,20 +431,19 @@ func TestLogError_Good(t *testing.T) {
|
|||
underlying := errors.New("connection failed")
|
||||
err := LogError(underlying, "db.Connect", "database unavailable")
|
||||
|
||||
// Check returned error
|
||||
assert.NotNil(t, err)
|
||||
assert.Contains(t, err.Error(), "db.Connect")
|
||||
assert.Contains(t, err.Error(), "database unavailable")
|
||||
assert.True(t, errors.Is(err, underlying))
|
||||
|
||||
// Check log output
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "[ERR]")
|
||||
assert.Contains(t, output, "database unavailable")
|
||||
assert.Contains(t, output, "op=\"db.Connect\"")
|
||||
}
|
||||
|
||||
func TestLogError_Good_NilError(t *testing.T) {
|
||||
func TestErrors_LogError_Bad(t *testing.T) {
|
||||
// nil error — returns nil, no log output
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
|
|
@ -261,10 +451,27 @@ func TestLogError_Good_NilError(t *testing.T) {
|
|||
|
||||
err := LogError(nil, "op", "msg")
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, buf.String()) // No log output for nil error
|
||||
assert.Empty(t, buf.String())
|
||||
}
|
||||
|
||||
func TestLogWarn_Good(t *testing.T) {
|
||||
func TestErrors_LogError_Ugly(t *testing.T) {
|
||||
// LogError on an already-wrapped *Err — error chain preserved
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
||||
|
||||
root := errors.New("root")
|
||||
inner := E("inner.Op", "inner failed", root)
|
||||
err := LogError(inner, "outer.Op", "outer context")
|
||||
|
||||
assert.True(t, errors.Is(err, root))
|
||||
assert.Contains(t, buf.String(), "[ERR]")
|
||||
}
|
||||
|
||||
// --- LogWarn ---
|
||||
|
||||
func TestErrors_LogWarn_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
|
|
@ -275,13 +482,12 @@ func TestLogWarn_Good(t *testing.T) {
|
|||
|
||||
assert.NotNil(t, err)
|
||||
assert.True(t, errors.Is(err, underlying))
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "[WRN]")
|
||||
assert.Contains(t, output, "falling back to db")
|
||||
assert.Contains(t, buf.String(), "[WRN]")
|
||||
assert.Contains(t, buf.String(), "falling back to db")
|
||||
}
|
||||
|
||||
func TestLogWarn_Good_NilError(t *testing.T) {
|
||||
func TestErrors_LogWarn_Bad(t *testing.T) {
|
||||
// nil error — returns nil, no log output
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
|
|
@ -292,14 +498,28 @@ func TestLogWarn_Good_NilError(t *testing.T) {
|
|||
assert.Empty(t, buf.String())
|
||||
}
|
||||
|
||||
func TestMust_Good_NoError(t *testing.T) {
|
||||
// Should not panic when error is nil
|
||||
func TestErrors_LogWarn_Ugly(t *testing.T) {
|
||||
// LogWarn at LevelError — [WRN] is suppressed since Warn < Error threshold
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelError, Output: &buf})
|
||||
SetDefault(logger)
|
||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
||||
|
||||
LogWarn(errors.New("warn level"), "op", "msg")
|
||||
assert.Empty(t, buf.String(), "expected warn suppressed at LevelError")
|
||||
}
|
||||
|
||||
// --- Must ---
|
||||
|
||||
func TestErrors_Must_Good(t *testing.T) {
|
||||
// nil error — no panic
|
||||
assert.NotPanics(t, func() {
|
||||
Must(nil, "test", "should not panic")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMust_Ugly_Panics(t *testing.T) {
|
||||
func TestErrors_Must_Bad(t *testing.T) {
|
||||
// Non-nil error — panics with wrapped error
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
|
|
@ -309,47 +529,21 @@ func TestMust_Ugly_Panics(t *testing.T) {
|
|||
Must(errors.New("fatal error"), "startup", "initialization failed")
|
||||
})
|
||||
|
||||
// Verify error was logged before panic
|
||||
output := buf.String()
|
||||
assert.True(t, strings.Contains(output, "[ERR]") || len(output) > 0)
|
||||
assert.True(t, core.Contains(output, "[ERR]") || len(output) > 0)
|
||||
}
|
||||
|
||||
func TestStackTrace_Good(t *testing.T) {
|
||||
// Nested operations
|
||||
err := E("op1", "msg1", nil)
|
||||
err = Wrap(err, "op2", "msg2")
|
||||
err = Wrap(err, "op3", "msg3")
|
||||
func TestErrors_Must_Ugly(t *testing.T) {
|
||||
// Panic value is a wrapped *Err, not the raw error
|
||||
var panicValue any
|
||||
func() {
|
||||
defer func() { panicValue = recover() }()
|
||||
Must(errors.New("root cause"), "init.Op", "startup failed")
|
||||
}()
|
||||
|
||||
stack := StackTrace(err)
|
||||
assert.Equal(t, []string{"op3", "op2", "op1"}, stack)
|
||||
|
||||
// Format
|
||||
formatted := FormatStackTrace(err)
|
||||
assert.Equal(t, "op3 -> op2 -> op1", formatted)
|
||||
}
|
||||
|
||||
func TestStackTrace_PlainError(t *testing.T) {
|
||||
err := errors.New("plain error")
|
||||
assert.Empty(t, StackTrace(err))
|
||||
assert.Empty(t, FormatStackTrace(err))
|
||||
}
|
||||
|
||||
func TestStackTrace_Nil(t *testing.T) {
|
||||
assert.Empty(t, StackTrace(nil))
|
||||
assert.Empty(t, FormatStackTrace(nil))
|
||||
}
|
||||
|
||||
func TestStackTrace_NoOp(t *testing.T) {
|
||||
err := &Err{Msg: "no op"}
|
||||
assert.Empty(t, StackTrace(err))
|
||||
assert.Empty(t, FormatStackTrace(err))
|
||||
}
|
||||
|
||||
func TestStackTrace_Mixed_Good(t *testing.T) {
|
||||
err := E("inner", "msg", nil)
|
||||
err = fmt.Errorf("wrapper: %w", err)
|
||||
err = Wrap(err, "outer", "msg")
|
||||
|
||||
stack := StackTrace(err)
|
||||
assert.Equal(t, []string{"outer", "inner"}, stack)
|
||||
assert.NotNil(t, panicValue)
|
||||
panicErr, ok := panicValue.(error)
|
||||
assert.True(t, ok, "panic value must be an error")
|
||||
assert.Contains(t, panicErr.Error(), "init.Op")
|
||||
assert.Contains(t, panicErr.Error(), "startup failed")
|
||||
}
|
||||
|
|
|
|||
9
go.mod
9
go.mod
|
|
@ -2,13 +2,14 @@ module dappco.re/go/core/log
|
|||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/stretchr/testify v1.11.1
|
||||
require (
|
||||
dappco.re/go/core v0.6.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
7
go.sum
7
go.sum
|
|
@ -1,17 +1,14 @@
|
|||
dappco.re/go/core v0.6.0 h1:0wmuO/UmCWXxJkxQ6XvVLnqkAuWitbd49PhxjCsplyk=
|
||||
dappco.re/go/core v0.6.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
|
|
|
|||
89
log.go
89
log.go
|
|
@ -6,6 +6,7 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"dappco.re/go/core"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"os"
|
||||
|
|
@ -108,6 +109,9 @@ type Options struct {
|
|||
var RotationWriterFactory func(RotationOptions) goio.WriteCloser
|
||||
|
||||
// New creates a new Logger with the given options.
|
||||
//
|
||||
// logger := log.New(log.Options{Level: log.LevelInfo, Output: os.Stderr})
|
||||
// logger := log.New(log.Options{Level: log.LevelDebug, RedactKeys: []string{"password"}})
|
||||
func New(opts Options) *Logger {
|
||||
output := opts.Output
|
||||
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
|
||||
|
|
@ -133,6 +137,8 @@ func New(opts Options) *Logger {
|
|||
func identity(s string) string { return s }
|
||||
|
||||
// SetLevel changes the log level.
|
||||
//
|
||||
// logger.SetLevel(log.LevelDebug)
|
||||
func (l *Logger) SetLevel(level Level) {
|
||||
l.mu.Lock()
|
||||
l.level = level
|
||||
|
|
@ -140,6 +146,8 @@ func (l *Logger) SetLevel(level Level) {
|
|||
}
|
||||
|
||||
// Level returns the current log level.
|
||||
//
|
||||
// current := logger.Level()
|
||||
func (l *Logger) Level() Level {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
|
@ -147,6 +155,8 @@ func (l *Logger) Level() Level {
|
|||
}
|
||||
|
||||
// SetOutput changes the output writer.
|
||||
//
|
||||
// logger.SetOutput(os.Stdout)
|
||||
func (l *Logger) SetOutput(w goio.Writer) {
|
||||
l.mu.Lock()
|
||||
l.output = w
|
||||
|
|
@ -154,6 +164,8 @@ func (l *Logger) SetOutput(w goio.Writer) {
|
|||
}
|
||||
|
||||
// SetRedactKeys sets the keys to be redacted.
|
||||
//
|
||||
// logger.SetRedactKeys("password", "token", "secret")
|
||||
func (l *Logger) SetRedactKeys(keys ...string) {
|
||||
l.mu.Lock()
|
||||
l.redactKeys = slices.Clone(keys)
|
||||
|
|
@ -176,33 +188,33 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
|||
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
|
||||
|
||||
// Automatically extract context from error if present in keyvals
|
||||
origLen := len(keyvals)
|
||||
for i := 0; i < origLen; i += 2 {
|
||||
if i+1 < origLen {
|
||||
originalLength := len(keyvals)
|
||||
for i := 0; i < originalLength; i += 2 {
|
||||
if i+1 < originalLength {
|
||||
if err, ok := keyvals[i+1].(error); ok {
|
||||
if op := Op(err); op != "" {
|
||||
// Check if op is already in keyvals
|
||||
hasOp := false
|
||||
hasOperationKey := false
|
||||
for j := 0; j < len(keyvals); j += 2 {
|
||||
if k, ok := keyvals[j].(string); ok && k == "op" {
|
||||
hasOp = true
|
||||
hasOperationKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOp {
|
||||
if !hasOperationKey {
|
||||
keyvals = append(keyvals, "op", op)
|
||||
}
|
||||
}
|
||||
if stack := FormatStackTrace(err); stack != "" {
|
||||
// Check if stack is already in keyvals
|
||||
hasStack := false
|
||||
hasStackKey := false
|
||||
for j := 0; j < len(keyvals); j += 2 {
|
||||
if k, ok := keyvals[j].(string); ok && k == "stack" {
|
||||
hasStack = true
|
||||
hasStackKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasStack {
|
||||
if !hasStackKey {
|
||||
keyvals = append(keyvals, "stack", stack)
|
||||
}
|
||||
}
|
||||
|
|
@ -211,12 +223,12 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Format key-value pairs
|
||||
var kvStr string
|
||||
var keyValueString string
|
||||
if len(keyvals) > 0 {
|
||||
kvStr = " "
|
||||
keyValueString = " "
|
||||
for i := 0; i < len(keyvals); i += 2 {
|
||||
if i > 0 {
|
||||
kvStr += " "
|
||||
keyValueString += " "
|
||||
}
|
||||
key := keyvals[i]
|
||||
var val any
|
||||
|
|
@ -225,24 +237,26 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Redaction logic
|
||||
keyStr := fmt.Sprintf("%v", key)
|
||||
keyStr := core.Sprintf("%v", key)
|
||||
if slices.Contains(redactKeys, keyStr) {
|
||||
val = "[REDACTED]"
|
||||
}
|
||||
|
||||
// Secure formatting to prevent log injection
|
||||
if s, ok := val.(string); ok {
|
||||
kvStr += fmt.Sprintf("%v=%q", key, s)
|
||||
keyValueString += core.Sprintf("%v=%q", key, s)
|
||||
} else {
|
||||
kvStr += fmt.Sprintf("%v=%v", key, val)
|
||||
keyValueString += core.Sprintf("%v=%v", key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr)
|
||||
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, keyValueString)
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional key-value pairs.
|
||||
//
|
||||
// logger.Debug("processing request", "method", "GET", "path", "/api/users")
|
||||
func (l *Logger) Debug(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelDebug) {
|
||||
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
|
||||
|
|
@ -250,6 +264,8 @@ func (l *Logger) Debug(msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Info logs an info message with optional key-value pairs.
|
||||
//
|
||||
// logger.Info("server started", "port", 8080)
|
||||
func (l *Logger) Info(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelInfo) {
|
||||
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
|
||||
|
|
@ -257,6 +273,8 @@ func (l *Logger) Info(msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Warn logs a warning message with optional key-value pairs.
|
||||
//
|
||||
// logger.Warn("high memory usage", "percent", 92)
|
||||
func (l *Logger) Warn(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelWarn) {
|
||||
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
|
||||
|
|
@ -264,6 +282,8 @@ func (l *Logger) Warn(msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Error logs an error message with optional key-value pairs.
|
||||
//
|
||||
// logger.Error("database connection failed", "err", err, "host", "db.local")
|
||||
func (l *Logger) Error(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelError) {
|
||||
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
|
||||
|
|
@ -273,6 +293,8 @@ func (l *Logger) Error(msg string, keyvals ...any) {
|
|||
// Security logs a security event with optional key-value pairs.
|
||||
// It uses LevelError to ensure security events are visible even in restrictive
|
||||
// log configurations.
|
||||
//
|
||||
// logger.Security("brute force detected", "ip", remoteAddr, "attempts", 50)
|
||||
func (l *Logger) Security(msg string, keyvals ...any) {
|
||||
if l.shouldLog(LevelError) {
|
||||
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
|
||||
|
|
@ -280,16 +302,17 @@ func (l *Logger) Security(msg string, keyvals ...any) {
|
|||
}
|
||||
|
||||
// Username returns the current system username.
|
||||
// It uses os/user for reliability and falls back to environment variables.
|
||||
//
|
||||
// owner := log.Username() // e.g. "deploy", "www-data"
|
||||
func Username() string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
// Fallback for environments where user lookup might fail
|
||||
if u := os.Getenv("USER"); u != "" {
|
||||
if u := core.Env("USER"); u != "" {
|
||||
return u
|
||||
}
|
||||
return os.Getenv("USERNAME")
|
||||
return core.Env("USERNAME")
|
||||
}
|
||||
|
||||
// --- Default logger ---
|
||||
|
|
@ -297,46 +320,64 @@ func Username() string {
|
|||
var defaultLogger = New(Options{Level: LevelInfo})
|
||||
|
||||
// Default returns the default logger.
|
||||
//
|
||||
// logger := log.Default()
|
||||
func Default() *Logger {
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
// SetDefault sets the default logger.
|
||||
//
|
||||
// log.SetDefault(customLogger)
|
||||
func SetDefault(l *Logger) {
|
||||
defaultLogger = l
|
||||
}
|
||||
|
||||
// SetLevel sets the default logger's level.
|
||||
//
|
||||
// log.SetLevel(log.LevelDebug)
|
||||
func SetLevel(level Level) {
|
||||
defaultLogger.SetLevel(level)
|
||||
}
|
||||
|
||||
// SetRedactKeys sets the default logger's redaction keys.
|
||||
//
|
||||
// log.SetRedactKeys("password", "token", "api_key")
|
||||
func SetRedactKeys(keys ...string) {
|
||||
defaultLogger.SetRedactKeys(keys...)
|
||||
}
|
||||
|
||||
// Debug logs to the default logger.
|
||||
// Debug logs a debug message to the default logger.
|
||||
//
|
||||
// log.Debug("cache lookup", "key", "user:42", "hit", false)
|
||||
func Debug(msg string, keyvals ...any) {
|
||||
defaultLogger.Debug(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Info logs to the default logger.
|
||||
// Info logs an informational message to the default logger.
|
||||
//
|
||||
// log.Info("server started", "port", 8080)
|
||||
func Info(msg string, keyvals ...any) {
|
||||
defaultLogger.Info(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Warn logs to the default logger.
|
||||
// Warn logs a warning message to the default logger.
|
||||
//
|
||||
// log.Warn("retrying", "attempt", 2, "max", 3)
|
||||
func Warn(msg string, keyvals ...any) {
|
||||
defaultLogger.Warn(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Error logs to the default logger.
|
||||
// Error logs an error message to the default logger.
|
||||
//
|
||||
// log.Error("request failed", "err", err, "path", r.URL.Path)
|
||||
func Error(msg string, keyvals ...any) {
|
||||
defaultLogger.Error(msg, keyvals...)
|
||||
}
|
||||
|
||||
// Security logs to the default logger.
|
||||
// Security logs a security event to the default logger.
|
||||
//
|
||||
// log.Security("login failed", "ip", remoteAddr, "user", username)
|
||||
func Security(msg string, keyvals ...any) {
|
||||
defaultLogger.Security(msg, keyvals...)
|
||||
}
|
||||
|
|
|
|||
802
log_test.go
802
log_test.go
|
|
@ -2,8 +2,8 @@ package log
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"dappco.re/go/core"
|
||||
goio "io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -12,146 +12,14 @@ type nopWriteCloser struct{ goio.Writer }
|
|||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
|
||||
func TestLogger_Levels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level Level
|
||||
logFunc func(*Logger, string, ...any)
|
||||
expected bool
|
||||
}{
|
||||
{"debug at debug", LevelDebug, (*Logger).Debug, true},
|
||||
{"info at debug", LevelDebug, (*Logger).Info, true},
|
||||
{"warn at debug", LevelDebug, (*Logger).Warn, true},
|
||||
{"error at debug", LevelDebug, (*Logger).Error, true},
|
||||
|
||||
{"debug at info", LevelInfo, (*Logger).Debug, false},
|
||||
{"info at info", LevelInfo, (*Logger).Info, true},
|
||||
{"warn at info", LevelInfo, (*Logger).Warn, true},
|
||||
{"error at info", LevelInfo, (*Logger).Error, true},
|
||||
|
||||
{"debug at warn", LevelWarn, (*Logger).Debug, false},
|
||||
{"info at warn", LevelWarn, (*Logger).Info, false},
|
||||
{"warn at warn", LevelWarn, (*Logger).Warn, true},
|
||||
{"error at warn", LevelWarn, (*Logger).Error, true},
|
||||
|
||||
{"debug at error", LevelError, (*Logger).Debug, false},
|
||||
{"info at error", LevelError, (*Logger).Info, false},
|
||||
{"warn at error", LevelError, (*Logger).Warn, false},
|
||||
{"error at error", LevelError, (*Logger).Error, true},
|
||||
|
||||
{"debug at quiet", LevelQuiet, (*Logger).Debug, false},
|
||||
{"info at quiet", LevelQuiet, (*Logger).Info, false},
|
||||
{"warn at quiet", LevelQuiet, (*Logger).Warn, false},
|
||||
{"error at quiet", LevelQuiet, (*Logger).Error, false},
|
||||
|
||||
{"security at info", LevelInfo, (*Logger).Security, true},
|
||||
{"security at error", LevelError, (*Logger).Security, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: tt.level, Output: &buf})
|
||||
tt.logFunc(l, "test message")
|
||||
|
||||
hasOutput := buf.Len() > 0
|
||||
if hasOutput != tt.expected {
|
||||
t.Errorf("expected output=%v, got output=%v", tt.expected, hasOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
func substringCount(s, substr string) int {
|
||||
return len(core.Split(s, substr)) - 1
|
||||
}
|
||||
|
||||
func TestLogger_KeyValues(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelDebug, Output: &buf})
|
||||
// --- Level.String ---
|
||||
|
||||
l.Info("test message", "key1", "value1", "key2", 42)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "test message") {
|
||||
t.Error("expected message in output")
|
||||
}
|
||||
if !strings.Contains(output, "key1=\"value1\"") {
|
||||
t.Errorf("expected key1=\"value1\" in output, got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "key2=42") {
|
||||
t.Error("expected key2=42 in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_ErrorContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Output: &buf, Level: LevelInfo})
|
||||
|
||||
err := E("test.Op", "failed", NewError("root cause"))
|
||||
err = Wrap(err, "outer.Op", "outer failed")
|
||||
|
||||
l.Error("something failed", "err", err)
|
||||
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "op=\"outer.Op\"") {
|
||||
t.Errorf("expected output to contain op=\"outer.Op\", got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "stack=\"outer.Op -> test.Op\"") {
|
||||
t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_Redaction(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
RedactKeys: []string{"password", "token"},
|
||||
})
|
||||
|
||||
l.Info("login", "user", "admin", "password", "secret123", "token", "abc-123")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "user=\"admin\"") {
|
||||
t.Error("expected user=\"admin\"")
|
||||
}
|
||||
if !strings.Contains(output, "password=\"[REDACTED]\"") {
|
||||
t.Errorf("expected password=\"[REDACTED]\", got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "token=\"[REDACTED]\"") {
|
||||
t.Errorf("expected token=\"[REDACTED]\", got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_InjectionPrevention(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
l.Info("message", "key", "value\n[SEC] injected message")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "key=\"value\\n[SEC] injected message\"") {
|
||||
t.Errorf("expected escaped newline, got %q", output)
|
||||
}
|
||||
// Ensure it's still a single line (excluding trailing newline)
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("expected 1 line, got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_SetLevel(t *testing.T) {
|
||||
l := New(Options{Level: LevelInfo})
|
||||
|
||||
if l.Level() != LevelInfo {
|
||||
t.Error("expected initial level to be Info")
|
||||
}
|
||||
|
||||
l.SetLevel(LevelDebug)
|
||||
if l.Level() != LevelDebug {
|
||||
t.Error("expected level to be Debug after SetLevel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevel_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
func TestLog_LevelString_Good(t *testing.T) {
|
||||
cases := []struct {
|
||||
level Level
|
||||
expected string
|
||||
}{
|
||||
|
|
@ -160,119 +28,81 @@ func TestLevel_String(t *testing.T) {
|
|||
{LevelWarn, "warn"},
|
||||
{LevelInfo, "info"},
|
||||
{LevelDebug, "debug"},
|
||||
{Level(99), "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expected, func(t *testing.T) {
|
||||
if got := tt.level.String(); got != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, got)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expected, func(t *testing.T) {
|
||||
if got := tc.level.String(); got != tc.expected {
|
||||
t.Errorf("expected %q, got %q", tc.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_Security(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelError, Output: &buf})
|
||||
|
||||
l.Security("unauthorized access", "user", "admin")
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "[SEC]") {
|
||||
t.Error("expected [SEC] prefix in security log")
|
||||
}
|
||||
if !strings.Contains(output, "unauthorized access") {
|
||||
t.Error("expected message in security log")
|
||||
}
|
||||
if !strings.Contains(output, "user=\"admin\"") {
|
||||
t.Error("expected context in security log")
|
||||
func TestLog_LevelString_Bad(t *testing.T) {
|
||||
// Invalid level value returns "unknown"
|
||||
if got := Level(99).String(); got != "unknown" {
|
||||
t.Errorf("expected \"unknown\" for out-of-range level, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_SetOutput_Good(t *testing.T) {
|
||||
var buf1, buf2 bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf1})
|
||||
|
||||
l.Info("first")
|
||||
if buf1.Len() == 0 {
|
||||
t.Error("expected output in first buffer")
|
||||
}
|
||||
|
||||
l.SetOutput(&buf2)
|
||||
l.Info("second")
|
||||
if buf2.Len() == 0 {
|
||||
t.Error("expected output in second buffer after SetOutput")
|
||||
func TestLog_LevelString_Ugly(t *testing.T) {
|
||||
// Negative level value also returns "unknown"
|
||||
if got := Level(-1).String(); got != "unknown" {
|
||||
t.Errorf("expected \"unknown\" for negative level, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_SetRedactKeys_Good(t *testing.T) {
|
||||
// --- New ---
|
||||
|
||||
func TestLog_New_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
// No redaction initially
|
||||
l.Info("msg", "secret", "visible")
|
||||
if !strings.Contains(buf.String(), "secret=\"visible\"") {
|
||||
t.Errorf("expected visible value, got %q", buf.String())
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
if logger == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
l.SetRedactKeys("secret")
|
||||
l.Info("msg", "secret", "hidden")
|
||||
if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") {
|
||||
t.Errorf("expected redacted value, got %q", buf.String())
|
||||
if logger.Level() != LevelInfo {
|
||||
t.Errorf("expected LevelInfo, got %v", logger.Level())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_OddKeyvals_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
// Odd number of keyvals — last key should have no value
|
||||
l.Info("msg", "lonely_key")
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "lonely_key=<nil>") {
|
||||
t.Errorf("expected lonely_key=<nil>, got %q", output)
|
||||
func TestLog_New_Bad(t *testing.T) {
|
||||
// No output specified — should default to stderr (not nil or panic)
|
||||
logger := New(Options{Level: LevelInfo})
|
||||
if logger.output == nil {
|
||||
t.Error("expected non-nil output when no Output specified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) {
|
||||
func TestLog_New_Ugly(t *testing.T) {
|
||||
// Rotation factory set but no filename — should fall back to Output
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||
original := RotationWriterFactory
|
||||
defer func() { RotationWriterFactory = original }()
|
||||
|
||||
err := E("inner.Op", "failed", NewError("cause"))
|
||||
// Pass op explicitly — should not duplicate
|
||||
l.Error("failed", "op", "explicit.Op", "err", err)
|
||||
|
||||
output := buf.String()
|
||||
if strings.Count(output, "op=") != 1 {
|
||||
t.Errorf("expected exactly one op= in output, got %q", output)
|
||||
called := false
|
||||
RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser {
|
||||
called = true
|
||||
return nopWriteCloser{&buf}
|
||||
}
|
||||
if !strings.Contains(output, "op=\"explicit.Op\"") {
|
||||
t.Errorf("expected explicit op, got %q", output)
|
||||
|
||||
// Rotation with empty Filename — factory must NOT be called
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
Rotation: &RotationOptions{Filename: ""},
|
||||
})
|
||||
if called {
|
||||
t.Error("expected rotation factory NOT called when Filename is empty")
|
||||
}
|
||||
if logger.output == nil {
|
||||
t.Error("expected non-nil output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) {
|
||||
// --- New with RotationWriterFactory ---
|
||||
|
||||
func TestLog_NewRotationFactory_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
err := E("inner.Op", "failed", NewError("cause"))
|
||||
// Pass stack explicitly — should not duplicate
|
||||
l.Error("failed", "stack", "custom.Stack", "err", err)
|
||||
|
||||
output := buf.String()
|
||||
if strings.Count(output, "stack=") != 1 {
|
||||
t.Errorf("expected exactly one stack= in output, got %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "stack=\"custom.Stack\"") {
|
||||
t.Errorf("expected custom stack, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_RotationFactory_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
// Set up a mock rotation writer factory
|
||||
original := RotationWriterFactory
|
||||
defer func() { RotationWriterFactory = original }()
|
||||
|
||||
|
|
@ -280,45 +110,509 @@ func TestNew_RotationFactory_Good(t *testing.T) {
|
|||
return nopWriteCloser{&buf}
|
||||
}
|
||||
|
||||
l := New(Options{
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Rotation: &RotationOptions{Filename: "test.log"},
|
||||
})
|
||||
|
||||
l.Info("rotated message")
|
||||
logger.Info("rotated message")
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output via rotation writer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultOutput_Good(t *testing.T) {
|
||||
// No output or rotation — should default to stderr (not nil)
|
||||
l := New(Options{Level: LevelInfo})
|
||||
if l.output == nil {
|
||||
t.Error("expected non-nil output when no Output specified")
|
||||
func TestLog_NewRotationFactory_Bad(t *testing.T) {
|
||||
// Rotation requested but RotationWriterFactory is nil — should fall back to Output
|
||||
var buf bytes.Buffer
|
||||
original := RotationWriterFactory
|
||||
defer func() { RotationWriterFactory = original }()
|
||||
RotationWriterFactory = nil
|
||||
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
Rotation: &RotationOptions{Filename: "test.log"},
|
||||
})
|
||||
|
||||
logger.Info("fallback message")
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected fallback to Output when RotationWriterFactory is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsername_Good(t *testing.T) {
|
||||
func TestLog_NewRotationFactory_Ugly(t *testing.T) {
|
||||
// Both Output and Rotation provided — Rotation wins when factory is set
|
||||
var rotationBuf, outputBuf bytes.Buffer
|
||||
original := RotationWriterFactory
|
||||
defer func() { RotationWriterFactory = original }()
|
||||
|
||||
RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser {
|
||||
return nopWriteCloser{&rotationBuf}
|
||||
}
|
||||
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &outputBuf,
|
||||
Rotation: &RotationOptions{Filename: "test.log"},
|
||||
})
|
||||
|
||||
logger.Info("which writer?")
|
||||
if rotationBuf.Len() == 0 {
|
||||
t.Error("expected rotation writer to be used")
|
||||
}
|
||||
if outputBuf.Len() != 0 {
|
||||
t.Error("expected Output to be ignored when Rotation is set")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SetLevel / Level ---
|
||||
|
||||
func TestLog_SetLevel_Good(t *testing.T) {
|
||||
logger := New(Options{Level: LevelInfo})
|
||||
if logger.Level() != LevelInfo {
|
||||
t.Error("expected initial level to be Info")
|
||||
}
|
||||
logger.SetLevel(LevelDebug)
|
||||
if logger.Level() != LevelDebug {
|
||||
t.Error("expected level to be Debug after SetLevel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetLevel_Bad(t *testing.T) {
|
||||
// SetLevel to Quiet suppresses all output
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
logger.SetLevel(LevelQuiet)
|
||||
logger.Error("should be suppressed")
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output at LevelQuiet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetLevel_Ugly(t *testing.T) {
|
||||
// SetLevel with an out-of-range value — should not panic, level stored as-is
|
||||
logger := New(Options{Level: LevelInfo})
|
||||
logger.SetLevel(Level(99))
|
||||
if logger.Level() != Level(99) {
|
||||
t.Errorf("expected level 99, got %v", logger.Level())
|
||||
}
|
||||
}
|
||||
|
||||
// --- SetOutput ---
|
||||
|
||||
func TestLog_SetOutput_Good(t *testing.T) {
|
||||
var buf1, buf2 bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf1})
|
||||
|
||||
logger.Info("first")
|
||||
if buf1.Len() == 0 {
|
||||
t.Error("expected output in first buffer")
|
||||
}
|
||||
|
||||
logger.SetOutput(&buf2)
|
||||
logger.Info("second")
|
||||
if buf2.Len() == 0 {
|
||||
t.Error("expected output in second buffer after SetOutput")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetOutput_Bad(t *testing.T) {
|
||||
// Output switched mid-stream — first buffer must not receive later messages
|
||||
var buf1, buf2 bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf1})
|
||||
logger.Info("first")
|
||||
first1Len := buf1.Len()
|
||||
|
||||
logger.SetOutput(&buf2)
|
||||
logger.Info("second")
|
||||
|
||||
if buf1.Len() != first1Len {
|
||||
t.Error("first buffer must not receive messages after SetOutput")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetOutput_Ugly(t *testing.T) {
|
||||
// SetOutput to goio.Discard — no panic, just silently discarded
|
||||
logger := New(Options{Level: LevelInfo})
|
||||
logger.SetOutput(goio.Discard)
|
||||
logger.Info("discarded message") // must not panic
|
||||
}
|
||||
|
||||
// --- SetRedactKeys ---
|
||||
|
||||
func TestLog_SetRedactKeys_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
// No redaction initially
|
||||
logger.Info("msg", "secret", "visible")
|
||||
if !core.Contains(buf.String(), "secret=\"visible\"") {
|
||||
t.Errorf("expected visible value, got %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
logger.SetRedactKeys("secret")
|
||||
logger.Info("msg", "secret", "hidden")
|
||||
if !core.Contains(buf.String(), "secret=\"[REDACTED]\"") {
|
||||
t.Errorf("expected redacted value, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetRedactKeys_Bad(t *testing.T) {
|
||||
// Calling SetRedactKeys with empty list clears all redaction
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf, RedactKeys: []string{"token"}})
|
||||
logger.SetRedactKeys() // clear all
|
||||
logger.Info("msg", "token", "visible-now")
|
||||
if !core.Contains(buf.String(), "token=\"visible-now\"") {
|
||||
t.Errorf("expected token to be visible after clearing redact keys, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_SetRedactKeys_Ugly(t *testing.T) {
|
||||
// Redact key that doesn't appear in log — no effect, no panic
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.SetRedactKeys("nonexistent_key")
|
||||
logger.Info("msg", "other", "value")
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "other=\"value\"") {
|
||||
t.Errorf("expected other to appear normally, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Debug / Info / Warn / Error / Security ---
|
||||
|
||||
func TestLog_Levels_Good(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
level Level
|
||||
logFunc func(*Logger, string, ...any)
|
||||
expect bool
|
||||
}{
|
||||
{"debug at debug", LevelDebug, (*Logger).Debug, true},
|
||||
{"info at info", LevelInfo, (*Logger).Info, true},
|
||||
{"warn at warn", LevelWarn, (*Logger).Warn, true},
|
||||
{"error at error", LevelError, (*Logger).Error, true},
|
||||
{"security at error", LevelError, (*Logger).Security, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: tc.level, Output: &buf})
|
||||
tc.logFunc(logger, "test message")
|
||||
if buf.Len() == 0 {
|
||||
t.Errorf("expected output for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Levels_Bad(t *testing.T) {
|
||||
// Messages below the active level are suppressed
|
||||
cases := []struct {
|
||||
name string
|
||||
level Level
|
||||
logFunc func(*Logger, string, ...any)
|
||||
}{
|
||||
{"debug at info", LevelInfo, (*Logger).Debug},
|
||||
{"debug at warn", LevelWarn, (*Logger).Debug},
|
||||
{"info at warn", LevelWarn, (*Logger).Info},
|
||||
{"debug at error", LevelError, (*Logger).Debug},
|
||||
{"info at error", LevelError, (*Logger).Info},
|
||||
{"warn at error", LevelError, (*Logger).Warn},
|
||||
{"debug at quiet", LevelQuiet, (*Logger).Debug},
|
||||
{"info at quiet", LevelQuiet, (*Logger).Info},
|
||||
{"warn at quiet", LevelQuiet, (*Logger).Warn},
|
||||
{"error at quiet", LevelQuiet, (*Logger).Error},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: tc.level, Output: &buf})
|
||||
tc.logFunc(logger, "test message")
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected no output for %s, got %q", tc.name, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Levels_Ugly(t *testing.T) {
|
||||
// Security always uses LevelError threshold — even at LevelWarn it should appear
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelWarn, Output: &buf})
|
||||
logger.Security("intrusion attempt", "ip", "10.0.0.1")
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected security log output at LevelWarn (security uses LevelError threshold)")
|
||||
}
|
||||
if !core.Contains(buf.String(), "[SEC]") {
|
||||
t.Errorf("expected [SEC] prefix, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Key-value formatting ---
|
||||
|
||||
func TestLog_KeyValues_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
|
||||
logger.Info("test message", "key1", "value1", "key2", 42)
|
||||
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "test message") {
|
||||
t.Error("expected message in output")
|
||||
}
|
||||
if !core.Contains(output, "key1=\"value1\"") {
|
||||
t.Errorf("expected key1=\"value1\" in output, got %q", output)
|
||||
}
|
||||
if !core.Contains(output, "key2=42") {
|
||||
t.Error("expected key2=42 in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_KeyValues_Bad(t *testing.T) {
|
||||
// Odd number of keyvals — last key has no value, rendered as <nil>
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.Info("msg", "lonely_key")
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "lonely_key=<nil>") {
|
||||
t.Errorf("expected lonely_key=<nil>, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_KeyValues_Ugly(t *testing.T) {
|
||||
// No keyvals at all — message alone, no trailing space
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.Info("just a message")
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "just a message") {
|
||||
t.Errorf("expected message in output, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Log injection prevention ---
|
||||
|
||||
func TestLog_InjectionPrevention_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
|
||||
logger.Info("message", "key", "value\n[SEC] injected message")
|
||||
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "key=\"value\\n[SEC] injected message\"") {
|
||||
t.Errorf("expected escaped newline, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_InjectionPrevention_Bad(t *testing.T) {
|
||||
// Embedded carriage return also escaped
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.Info("message", "key", "value\r\ninjected")
|
||||
output := buf.String()
|
||||
// The value must be quoted (escaped), so it remains one log line
|
||||
lines := core.Split(core.Trim(output), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("expected 1 line after injection attempt, got %d: %q", len(lines), output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_InjectionPrevention_Ugly(t *testing.T) {
|
||||
// Tab and null bytes in value — must be quoted, no panic
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.Info("message", "key", "val\t\x00ue")
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected output even with unusual characters in value")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Redaction ---
|
||||
|
||||
func TestLog_Redaction_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
RedactKeys: []string{"password", "token"},
|
||||
})
|
||||
|
||||
logger.Info("login", "user", "admin", "password", "secret123", "token", "abc-123")
|
||||
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "user=\"admin\"") {
|
||||
t.Error("expected user=\"admin\"")
|
||||
}
|
||||
if !core.Contains(output, "password=\"[REDACTED]\"") {
|
||||
t.Errorf("expected password=\"[REDACTED]\", got %q", output)
|
||||
}
|
||||
if !core.Contains(output, "token=\"[REDACTED]\"") {
|
||||
t.Errorf("expected token=\"[REDACTED]\", got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Redaction_Bad(t *testing.T) {
|
||||
// Non-string value under a redacted key is also masked
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
RedactKeys: []string{"pin"},
|
||||
})
|
||||
logger.Info("auth", "pin", 1234)
|
||||
if !core.Contains(buf.String(), "pin=\"[REDACTED]\"") {
|
||||
t.Errorf("expected numeric pin to be redacted, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Redaction_Ugly(t *testing.T) {
|
||||
// Redact key that appears multiple times — all occurrences masked
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{
|
||||
Level: LevelInfo,
|
||||
Output: &buf,
|
||||
RedactKeys: []string{"x"},
|
||||
})
|
||||
logger.Info("msg", "x", "first", "y", "plain", "x", "second")
|
||||
output := buf.String()
|
||||
count := substringCount(output, "[REDACTED]")
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 redactions, got %d in %q", count, output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Error context auto-extraction ---
|
||||
|
||||
func TestLog_ErrorContext_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Output: &buf, Level: LevelInfo})
|
||||
|
||||
err := E("test.Op", "failed", NewError("root cause"))
|
||||
err = Wrap(err, "outer.Op", "outer failed")
|
||||
|
||||
logger.Error("something failed", "err", err)
|
||||
|
||||
got := buf.String()
|
||||
if !core.Contains(got, "op=\"outer.Op\"") {
|
||||
t.Errorf("expected output to contain op=\"outer.Op\", got %q", got)
|
||||
}
|
||||
if !core.Contains(got, "stack=\"outer.Op -> test.Op\"") {
|
||||
t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_ErrorContext_Bad(t *testing.T) {
|
||||
// Plain error (not *Err) — no op or stack injected
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Output: &buf, Level: LevelInfo})
|
||||
logger.Error("plain error", "err", NewError("plain"))
|
||||
output := buf.String()
|
||||
if core.Contains(output, "op=") {
|
||||
t.Errorf("expected no op= for plain error, got %q", output)
|
||||
}
|
||||
if core.Contains(output, "stack=") {
|
||||
t.Errorf("expected no stack= for plain error, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_ErrorContext_Ugly(t *testing.T) {
|
||||
// Caller provides op explicitly — must not duplicate
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
err := E("inner.Op", "failed", NewError("cause"))
|
||||
logger.Error("failed", "op", "explicit.Op", "err", err)
|
||||
output := buf.String()
|
||||
if substringCount(output, "op=") != 1 {
|
||||
t.Errorf("expected exactly one op= in output, got %q", output)
|
||||
}
|
||||
if !core.Contains(output, "op=\"explicit.Op\"") {
|
||||
t.Errorf("expected explicit op, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Security ---
|
||||
|
||||
func TestLog_Security_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelError, Output: &buf})
|
||||
|
||||
logger.Security("unauthorized access", "user", "admin")
|
||||
|
||||
output := buf.String()
|
||||
if !core.Contains(output, "[SEC]") {
|
||||
t.Error("expected [SEC] prefix in security log")
|
||||
}
|
||||
if !core.Contains(output, "unauthorized access") {
|
||||
t.Error("expected message in security log")
|
||||
}
|
||||
if !core.Contains(output, "user=\"admin\"") {
|
||||
t.Error("expected context in security log")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Security_Bad(t *testing.T) {
|
||||
// Security is suppressed at LevelQuiet
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelQuiet, Output: &buf})
|
||||
logger.Security("suppressed", "ip", "1.2.3.4")
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected security log suppressed at LevelQuiet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Security_Ugly(t *testing.T) {
|
||||
// Security message with no key-value pairs — no panic, correct prefix
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelInfo, Output: &buf})
|
||||
logger.Security("bare security event")
|
||||
if !core.Contains(buf.String(), "[SEC]") {
|
||||
t.Errorf("expected [SEC] prefix even with no kvs, got %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Username ---
|
||||
|
||||
func TestLog_Username_Good(t *testing.T) {
|
||||
name := Username()
|
||||
if name == "" {
|
||||
t.Error("expected Username to return a non-empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefault_Good(t *testing.T) {
|
||||
func TestLog_Username_Bad(t *testing.T) {
|
||||
// Username must not contain a newline (common mistake from raw /etc/passwd reads)
|
||||
name := Username()
|
||||
if core.Contains(name, "\n") {
|
||||
t.Errorf("Username must not contain newline, got %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Username_Ugly(t *testing.T) {
|
||||
// Username is idempotent — two calls return the same value
|
||||
first := Username()
|
||||
second := Username()
|
||||
if first != second {
|
||||
t.Errorf("Username not idempotent: %q vs %q", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Default logger and package-level proxies ---
|
||||
|
||||
func TestLog_Default_Good(t *testing.T) {
|
||||
if Default() == nil {
|
||||
t.Error("expected default logger to exist")
|
||||
}
|
||||
|
||||
// All package-level proxy functions
|
||||
var buf bytes.Buffer
|
||||
l := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(l)
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
||||
|
||||
SetLevel(LevelDebug)
|
||||
if l.Level() != LevelDebug {
|
||||
if logger.Level() != LevelDebug {
|
||||
t.Error("expected package-level SetLevel to work")
|
||||
}
|
||||
|
||||
|
|
@ -332,8 +626,40 @@ func TestDefault_Good(t *testing.T) {
|
|||
|
||||
output := buf.String()
|
||||
for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} {
|
||||
if !strings.Contains(output, tag) {
|
||||
if !core.Contains(output, tag) {
|
||||
t.Errorf("expected %s in output, got %q", tag, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Default_Bad(t *testing.T) {
|
||||
// SetDefault(nil) must not panic immediately — panics only on use
|
||||
// Test that Default() returns the newly set logger
|
||||
var buf bytes.Buffer
|
||||
custom := New(Options{Level: LevelWarn, Output: &buf})
|
||||
SetDefault(custom)
|
||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
||||
|
||||
if Default() != custom {
|
||||
t.Error("expected Default() to return the custom logger after SetDefault")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLog_Default_Ugly(t *testing.T) {
|
||||
// Stack de-duplication via package-level proxy
|
||||
var buf bytes.Buffer
|
||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||
SetDefault(logger)
|
||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
||||
|
||||
err := E("inner.Op", "failed", NewError("cause"))
|
||||
Error("failed", "stack", "custom.Stack", "err", err)
|
||||
|
||||
output := buf.String()
|
||||
if substringCount(output, "stack=") != 1 {
|
||||
t.Errorf("expected exactly one stack= in output, got %q", output)
|
||||
}
|
||||
if !core.Contains(output, "stack=\"custom.Stack\"") {
|
||||
t.Errorf("expected custom stack, got %q", output)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue