Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03642ec746 | |||
| 5eaea6ada4 | |||
|
|
e4d86eb0fb | ||
| c656fed80b | |||
|
|
3423bac33f | ||
| 7c984b22dc | |||
|
|
318a948a33 | ||
| 6008ca5c3a | |||
|
|
426b164b75 |
12 changed files with 354 additions and 1103 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 |
|
||||||
|
|
@ -63,9 +63,6 @@ type Err struct {
|
||||||
Msg string // human-readable description
|
Msg string // human-readable description
|
||||||
Err error // underlying cause (optional)
|
Err error // underlying cause (optional)
|
||||||
Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED")
|
Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED")
|
||||||
Retryable bool // whether the caller can retry the operation
|
|
||||||
RetryAfter *time.Duration // optional retry delay hint
|
|
||||||
NextAction string // suggested next step when not retryable
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -118,9 +115,6 @@ log(LevelInfo, "[INF]", ...)
|
||||||
| if any value implements `error`:
|
| if any value implements `error`:
|
||||||
| extract Op -> append "op" key if not already present
|
| extract Op -> append "op" key if not already present
|
||||||
| extract FormatStackTrace -> append "stack" key if not already present
|
| extract FormatStackTrace -> append "stack" key if not already present
|
||||||
| extract recovery hints -> append "retryable",
|
|
||||||
| "retry_after_seconds",
|
|
||||||
| "next_action" if not already present
|
|
||||||
+-- format key-value pairs:
|
+-- format key-value pairs:
|
||||||
| string values -> %q (quoted, injection-safe)
|
| string values -> %q (quoted, injection-safe)
|
||||||
| other values -> %v
|
| other values -> %v
|
||||||
|
|
|
||||||
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`
|
||||||
|
|
@ -5,7 +5,7 @@ description: Structured logging and error handling for Core applications
|
||||||
|
|
||||||
# go-log
|
# go-log
|
||||||
|
|
||||||
`dappco.re/go/core/log` provides structured logging and contextual error
|
`forge.lthn.ai/core/go-log` provides structured logging and contextual error
|
||||||
handling for Go applications built on the Core framework. It is a small,
|
handling for Go applications built on the Core framework. It is a small,
|
||||||
zero-dependency library (only `testify` at test time) that replaces ad-hoc
|
zero-dependency library (only `testify` at test time) that replaces ad-hoc
|
||||||
`fmt.Println` / `log.Printf` calls with level-filtered, key-value structured
|
`fmt.Println` / `log.Printf` calls with level-filtered, key-value structured
|
||||||
|
|
@ -15,7 +15,7 @@ stack.
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "dappco.re/go/core/log"
|
import "forge.lthn.ai/core/go-log"
|
||||||
|
|
||||||
// Use the package-level default logger straight away
|
// Use the package-level default logger straight away
|
||||||
log.SetLevel(log.LevelDebug)
|
log.SetLevel(log.LevelDebug)
|
||||||
|
|
@ -54,10 +54,6 @@ log.Op(err) // "user.Save"
|
||||||
log.Root(err) // the original underlyingErr
|
log.Root(err) // the original underlyingErr
|
||||||
log.StackTrace(err) // ["user.Save", "db.Connect"]
|
log.StackTrace(err) // ["user.Save", "db.Connect"]
|
||||||
log.FormatStackTrace(err) // "user.Save -> db.Connect"
|
log.FormatStackTrace(err) // "user.Save -> db.Connect"
|
||||||
|
|
||||||
// Recovery hints are also available when the error carries them
|
|
||||||
log.IsRetryable(err) // false unless a wrapped Err marks it retryable
|
|
||||||
log.RecoveryAction(err) // "retry with backoff" when provided
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Combined Log-and-Return
|
### Combined Log-and-Return
|
||||||
|
|
@ -74,7 +70,7 @@ if err != nil {
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper |
|
| `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper |
|
||||||
| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, and recovery-aware variants), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`, recovery hints), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) |
|
| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) |
|
||||||
| `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging |
|
| `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging |
|
||||||
| `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers |
|
| `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers |
|
||||||
|
|
||||||
|
|
@ -93,7 +89,7 @@ code.
|
||||||
## Module Path
|
## Module Path
|
||||||
|
|
||||||
```
|
```
|
||||||
dappco.re/go/core/log
|
forge.lthn.ai/core/go-log
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires **Go 1.26+** (uses `iter.Seq` from the standard library).
|
Requires **Go 1.26+** (uses `iter.Seq` from the standard library).
|
||||||
|
|
|
||||||
232
errors.go
232
errors.go
|
|
@ -6,10 +6,9 @@
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core"
|
||||||
"errors"
|
"errors"
|
||||||
"iter"
|
"iter"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Err represents a structured error with operational context.
|
// Err represents a structured error with operational context.
|
||||||
|
|
@ -19,44 +18,24 @@ type Err struct {
|
||||||
Msg string // Human-readable message
|
Msg string // Human-readable message
|
||||||
Err error // Underlying error (optional)
|
Err error // Underlying error (optional)
|
||||||
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
|
Code string // Error code (optional, e.g., "VALIDATION_FAILED")
|
||||||
// Retryable indicates whether the caller can safely retry this error.
|
|
||||||
Retryable bool
|
|
||||||
// RetryAfter suggests a delay before retrying when Retryable is true.
|
|
||||||
RetryAfter *time.Duration
|
|
||||||
// NextAction suggests an alternative path when this error is not directly retryable.
|
|
||||||
NextAction string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error implements the error interface.
|
// Error implements the error interface.
|
||||||
func (e *Err) Error() string {
|
func (e *Err) Error() string {
|
||||||
if e == nil {
|
var prefix string
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
body := e.Msg
|
|
||||||
if body == "" {
|
|
||||||
if e.Code != "" {
|
|
||||||
body = "[" + e.Code + "]"
|
|
||||||
}
|
|
||||||
} else if e.Code != "" {
|
|
||||||
body += " [" + e.Code + "]"
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.Err != nil {
|
|
||||||
if body != "" {
|
|
||||||
body += ": " + e.Err.Error()
|
|
||||||
} else {
|
|
||||||
body = e.Err.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.Op != "" {
|
if e.Op != "" {
|
||||||
if body != "" {
|
prefix = e.Op + ": "
|
||||||
return e.Op + ": " + body
|
|
||||||
}
|
|
||||||
return e.Op
|
|
||||||
}
|
}
|
||||||
return body
|
if e.Err != nil {
|
||||||
|
if e.Code != "" {
|
||||||
|
return core.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
|
||||||
|
}
|
||||||
|
return core.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
|
||||||
|
}
|
||||||
|
if e.Code != "" {
|
||||||
|
return core.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
|
||||||
|
}
|
||||||
|
return core.Sprintf("%s%s", prefix, e.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap returns the underlying error for use with errors.Is and errors.As.
|
// Unwrap returns the underlying error for use with errors.Is and errors.As.
|
||||||
|
|
@ -77,22 +56,6 @@ func E(op, msg string, err error) error {
|
||||||
return &Err{Op: op, Msg: msg, Err: err}
|
return &Err{Op: op, Msg: msg, Err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EWithRecovery creates a new Err with operation context and recovery metadata.
|
|
||||||
//
|
|
||||||
// return log.EWithRecovery("api.Call", "temporary failure", err, true, &retryAfter, "retry with backoff")
|
|
||||||
func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error {
|
|
||||||
recoveryErr := &Err{
|
|
||||||
Op: op,
|
|
||||||
Msg: msg,
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
inheritRecovery(recoveryErr, err)
|
|
||||||
recoveryErr.Retryable = retryable
|
|
||||||
recoveryErr.RetryAfter = retryAfter
|
|
||||||
recoveryErr.NextAction = nextAction
|
|
||||||
return recoveryErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap wraps an error with operation context.
|
// Wrap wraps an error with operation context.
|
||||||
// Returns nil if err is nil, to support conditional wrapping.
|
// Returns nil if err is nil, to support conditional wrapping.
|
||||||
// Preserves error Code if the wrapped error is an *Err.
|
// Preserves error Code if the wrapped error is an *Err.
|
||||||
|
|
@ -104,29 +67,12 @@ func Wrap(err error, op, msg string) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
wrapped := &Err{Op: op, Msg: msg, Err: err, Code: inheritedCode(err)}
|
// Preserve Code from wrapped *Err
|
||||||
inheritRecovery(wrapped, err)
|
var logErr *Err
|
||||||
return wrapped
|
if As(err, &logErr) && logErr.Code != "" {
|
||||||
}
|
return &Err{Op: op, Msg: msg, Err: err, Code: logErr.Code}
|
||||||
|
|
||||||
// WrapWithRecovery wraps an error with operation context and explicit recovery metadata.
|
|
||||||
//
|
|
||||||
// return log.WrapWithRecovery(err, "api.Call", "temporary failure", true, &retryAfter, "retry with backoff")
|
|
||||||
func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
recoveryErr := &Err{
|
return &Err{Op: op, Msg: msg, Err: err}
|
||||||
Op: op,
|
|
||||||
Msg: msg,
|
|
||||||
Err: err,
|
|
||||||
Code: ErrCode(err),
|
|
||||||
}
|
|
||||||
inheritRecovery(recoveryErr, err)
|
|
||||||
recoveryErr.Retryable = retryable
|
|
||||||
recoveryErr.RetryAfter = retryAfter
|
|
||||||
recoveryErr.NextAction = nextAction
|
|
||||||
return recoveryErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapCode wraps an error with operation context and error code.
|
// WrapCode wraps an error with operation context and error code.
|
||||||
|
|
@ -140,29 +86,7 @@ func WrapCode(err error, code, op, msg string) error {
|
||||||
if err == nil && code == "" {
|
if err == nil && code == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
wrapped := &Err{Op: op, Msg: msg, Err: err, Code: code}
|
return &Err{Op: op, Msg: msg, Err: err, Code: code}
|
||||||
inheritRecovery(wrapped, err)
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata.
|
|
||||||
//
|
|
||||||
// return log.WrapCodeWithRecovery(err, "TEMPORARY_UNAVAILABLE", "api.Call", "temporary failure", true, &retryAfter, "retry with backoff")
|
|
||||||
func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
|
|
||||||
if err == nil && code == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
recoveryErr := &Err{
|
|
||||||
Op: op,
|
|
||||||
Msg: msg,
|
|
||||||
Err: err,
|
|
||||||
Code: code,
|
|
||||||
}
|
|
||||||
inheritRecovery(recoveryErr, err)
|
|
||||||
recoveryErr.Retryable = retryable
|
|
||||||
recoveryErr.RetryAfter = retryAfter
|
|
||||||
recoveryErr.NextAction = nextAction
|
|
||||||
return recoveryErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCode creates an error with just code and message (no underlying error).
|
// NewCode creates an error with just code and message (no underlying error).
|
||||||
|
|
@ -175,121 +99,28 @@ func NewCode(code, msg string) error {
|
||||||
return &Err{Msg: msg, Code: code}
|
return &Err{Msg: msg, Code: code}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCodeWithRecovery creates a coded error with recovery metadata.
|
|
||||||
//
|
|
||||||
// var ErrTemporary = log.NewCodeWithRecovery("TEMPORARY_UNAVAILABLE", "temporary failure", true, &retryAfter, "retry with backoff")
|
|
||||||
func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error {
|
|
||||||
return &Err{
|
|
||||||
Msg: msg,
|
|
||||||
Code: code,
|
|
||||||
Retryable: retryable,
|
|
||||||
RetryAfter: retryAfter,
|
|
||||||
NextAction: nextAction,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// inheritRecovery copies recovery metadata from the first *Err in err's chain.
|
|
||||||
func inheritRecovery(dst *Err, err error) {
|
|
||||||
if err == nil || dst == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var source *Err
|
|
||||||
if As(err, &source) {
|
|
||||||
dst.Retryable = source.Retryable
|
|
||||||
dst.RetryAfter = source.RetryAfter
|
|
||||||
dst.NextAction = source.NextAction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// inheritedCode returns the first non-empty code found in an error chain.
|
|
||||||
func inheritedCode(err error) string {
|
|
||||||
for err != nil {
|
|
||||||
if wrapped, ok := err.(*Err); ok && wrapped.Code != "" {
|
|
||||||
return wrapped.Code
|
|
||||||
}
|
|
||||||
err = errors.Unwrap(err)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryAfter returns the first retry-after hint from an error chain, if present.
|
|
||||||
//
|
|
||||||
// retryAfter, ok := log.RetryAfter(err)
|
|
||||||
func RetryAfter(err error) (*time.Duration, bool) {
|
|
||||||
for err != nil {
|
|
||||||
if wrapped, ok := err.(*Err); ok && wrapped.RetryAfter != nil {
|
|
||||||
return wrapped.RetryAfter, true
|
|
||||||
}
|
|
||||||
err = errors.Unwrap(err)
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRetryable reports whether the error chain contains a retryable Err.
|
|
||||||
//
|
|
||||||
// if log.IsRetryable(err) { /* retry the operation */ }
|
|
||||||
func IsRetryable(err error) bool {
|
|
||||||
var wrapped *Err
|
|
||||||
if As(err, &wrapped) {
|
|
||||||
return wrapped.Retryable
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecoveryAction returns the first next action from an error chain.
|
|
||||||
//
|
|
||||||
// next := log.RecoveryAction(err)
|
|
||||||
func RecoveryAction(err error) string {
|
|
||||||
for err != nil {
|
|
||||||
if wrapped, ok := err.(*Err); ok && wrapped.NextAction != "" {
|
|
||||||
return wrapped.NextAction
|
|
||||||
}
|
|
||||||
err = errors.Unwrap(err)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func retryableHint(err error) bool {
|
|
||||||
for err != nil {
|
|
||||||
if wrapped, ok := err.(*Err); ok && wrapped.Retryable {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
err = errors.Unwrap(err)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Standard Library Wrappers ---
|
// --- Standard Library Wrappers ---
|
||||||
|
|
||||||
// Is reports whether any error in err's tree matches target.
|
// Is reports whether any error in err's tree matches target.
|
||||||
// Wrapper around errors.Is for convenience.
|
// Wrapper around errors.Is for convenience.
|
||||||
//
|
|
||||||
// if log.Is(err, context.DeadlineExceeded) { /* handle timeout */ }
|
|
||||||
func Is(err, target error) bool {
|
func Is(err, target error) bool {
|
||||||
return errors.Is(err, target)
|
return errors.Is(err, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// As finds the first error in err's tree that matches target.
|
// As finds the first error in err's tree that matches target.
|
||||||
// Wrapper around errors.As for convenience.
|
// Wrapper around errors.As for convenience.
|
||||||
//
|
|
||||||
// var e *log.Err
|
|
||||||
// if log.As(err, &e) { /* use e.Code */ }
|
|
||||||
func As(err error, target any) bool {
|
func As(err error, target any) bool {
|
||||||
return errors.As(err, target)
|
return errors.As(err, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewError creates a simple error with the given text.
|
// NewError creates a simple error with the given text.
|
||||||
// Wrapper around errors.New for convenience.
|
// Wrapper around errors.New for convenience.
|
||||||
//
|
|
||||||
// return log.NewError("invalid state")
|
|
||||||
func NewError(text string) error {
|
func NewError(text string) error {
|
||||||
return errors.New(text)
|
return errors.New(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join combines multiple errors into one.
|
// Join combines multiple errors into one.
|
||||||
// Wrapper around errors.Join for convenience.
|
// Wrapper around errors.Join for convenience.
|
||||||
//
|
|
||||||
// return log.Join(validateErr, persistErr)
|
|
||||||
func Join(errs ...error) error {
|
func Join(errs ...error) error {
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
@ -298,8 +129,6 @@ func Join(errs ...error) error {
|
||||||
|
|
||||||
// Op extracts the operation name from an error.
|
// Op extracts the operation name from an error.
|
||||||
// Returns empty string if the error is not an *Err.
|
// Returns empty string if the error is not an *Err.
|
||||||
//
|
|
||||||
// op := log.Op(err) // e.g. "user.Save"
|
|
||||||
func Op(err error) string {
|
func Op(err error) string {
|
||||||
var e *Err
|
var e *Err
|
||||||
if As(err, &e) {
|
if As(err, &e) {
|
||||||
|
|
@ -310,8 +139,6 @@ func Op(err error) string {
|
||||||
|
|
||||||
// ErrCode extracts the error code from an error.
|
// ErrCode extracts the error code from an error.
|
||||||
// Returns empty string if the error is not an *Err or has no code.
|
// 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 {
|
func ErrCode(err error) string {
|
||||||
var e *Err
|
var e *Err
|
||||||
if As(err, &e) {
|
if As(err, &e) {
|
||||||
|
|
@ -322,8 +149,6 @@ func ErrCode(err error) string {
|
||||||
|
|
||||||
// Message extracts the message from an error.
|
// Message extracts the message from an error.
|
||||||
// Returns the error's Error() string if not an *Err.
|
// Returns the error's Error() string if not an *Err.
|
||||||
//
|
|
||||||
// msg := log.Message(err)
|
|
||||||
func Message(err error) string {
|
func Message(err error) string {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -337,8 +162,6 @@ func Message(err error) string {
|
||||||
|
|
||||||
// Root returns the root cause of an error chain.
|
// Root returns the root cause of an error chain.
|
||||||
// Unwraps until no more wrapped errors are found.
|
// Unwraps until no more wrapped errors are found.
|
||||||
//
|
|
||||||
// cause := log.Root(err)
|
|
||||||
func Root(err error) error {
|
func Root(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -354,8 +177,6 @@ func Root(err error) error {
|
||||||
|
|
||||||
// AllOps returns an iterator over all operational contexts in the error chain.
|
// AllOps returns an iterator over all operational contexts in the error chain.
|
||||||
// It traverses the error tree using errors.Unwrap.
|
// 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] {
|
func AllOps(err error) iter.Seq[string] {
|
||||||
return func(yield func(string) bool) {
|
return func(yield func(string) bool) {
|
||||||
for err != nil {
|
for err != nil {
|
||||||
|
|
@ -373,8 +194,6 @@ func AllOps(err error) iter.Seq[string] {
|
||||||
|
|
||||||
// StackTrace returns the logical stack trace (chain of operations) from an error.
|
// StackTrace returns the logical stack trace (chain of operations) from an error.
|
||||||
// It returns an empty slice if no operational context is found.
|
// 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 {
|
func StackTrace(err error) []string {
|
||||||
var stack []string
|
var stack []string
|
||||||
for op := range AllOps(err) {
|
for op := range AllOps(err) {
|
||||||
|
|
@ -384,8 +203,6 @@ func StackTrace(err error) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatStackTrace returns a pretty-printed logical stack trace.
|
// FormatStackTrace returns a pretty-printed logical stack trace.
|
||||||
//
|
|
||||||
// trace := log.FormatStackTrace(err) // "api.Call -> db.Query -> sql.Exec"
|
|
||||||
func FormatStackTrace(err error) string {
|
func FormatStackTrace(err error) string {
|
||||||
var ops []string
|
var ops []string
|
||||||
for op := range AllOps(err) {
|
for op := range AllOps(err) {
|
||||||
|
|
@ -394,7 +211,7 @@ func FormatStackTrace(err error) string {
|
||||||
if len(ops) == 0 {
|
if len(ops) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.Join(ops, " -> ")
|
return core.Join(" -> ", ops...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Combined Log-and-Return Helpers ---
|
// --- Combined Log-and-Return Helpers ---
|
||||||
|
|
@ -419,7 +236,7 @@ func LogError(err error, op, msg string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
wrapped := Wrap(err, op, msg)
|
wrapped := Wrap(err, op, msg)
|
||||||
Default().Error(msg, "op", op, "err", err)
|
defaultLogger.Error(msg, "op", op, "err", err)
|
||||||
return wrapped
|
return wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,7 +251,7 @@ func LogWarn(err error, op, msg string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
wrapped := Wrap(err, op, msg)
|
wrapped := Wrap(err, op, msg)
|
||||||
Default().Warn(msg, "op", op, "err", err)
|
defaultLogger.Warn(msg, "op", op, "err", err)
|
||||||
return wrapped
|
return wrapped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,8 +263,7 @@ func LogWarn(err error, op, msg string) error {
|
||||||
// log.Must(Initialize(), "app", "startup failed")
|
// log.Must(Initialize(), "app", "startup failed")
|
||||||
func Must(err error, op, msg string) {
|
func Must(err error, op, msg string) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wrapped := Wrap(err, op, msg)
|
defaultLogger.Error(msg, "op", op, "err", err)
|
||||||
Default().Error(msg, "op", op, "err", err)
|
panic(Wrap(err, op, msg))
|
||||||
panic(wrapped)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
175
errors_test.go
175
errors_test.go
|
|
@ -2,11 +2,10 @@ package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"dappco.re/go/core"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
@ -45,20 +44,6 @@ func TestErr_Error_EmptyOp_Good(t *testing.T) {
|
||||||
assert.Equal(t, "wrapped: underlying", err.Error())
|
assert.Equal(t, "wrapped: underlying", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErr_Error_EmptyMsg_Good(t *testing.T) {
|
|
||||||
err := &Err{Op: "api.Call", Code: "TIMEOUT"}
|
|
||||||
assert.Equal(t, "api.Call: [TIMEOUT]", err.Error())
|
|
||||||
|
|
||||||
err = &Err{Op: "api.Call", Err: errors.New("underlying")}
|
|
||||||
assert.Equal(t, "api.Call: underlying", err.Error())
|
|
||||||
|
|
||||||
err = &Err{Op: "api.Call", Code: "TIMEOUT", Err: errors.New("underlying")}
|
|
||||||
assert.Equal(t, "api.Call: [TIMEOUT]: underlying", err.Error())
|
|
||||||
|
|
||||||
err = &Err{Op: "api.Call"}
|
|
||||||
assert.Equal(t, "api.Call", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErr_Unwrap_Good(t *testing.T) {
|
func TestErr_Unwrap_Good(t *testing.T) {
|
||||||
underlying := errors.New("underlying error")
|
underlying := errors.New("underlying error")
|
||||||
err := &Err{Op: "test", Msg: "wrapped", Err: underlying}
|
err := &Err{Op: "test", Msg: "wrapped", Err: underlying}
|
||||||
|
|
@ -88,20 +73,6 @@ func TestE_Good_NilError(t *testing.T) {
|
||||||
assert.Equal(t, "op.Name: message", err.Error())
|
assert.Equal(t, "op.Name: message", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEWithRecovery_Good(t *testing.T) {
|
|
||||||
retryAfter := time.Second * 5
|
|
||||||
err := EWithRecovery("op.Name", "message", nil, true, &retryAfter, "retry once")
|
|
||||||
|
|
||||||
var logErr *Err
|
|
||||||
assert.NotNil(t, err)
|
|
||||||
assert.True(t, As(err, &logErr))
|
|
||||||
assert.True(t, logErr.Retryable)
|
|
||||||
if assert.NotNil(t, logErr.RetryAfter) {
|
|
||||||
assert.Equal(t, retryAfter, *logErr.RetryAfter)
|
|
||||||
}
|
|
||||||
assert.Equal(t, "retry once", logErr.NextAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrap_Good(t *testing.T) {
|
func TestWrap_Good(t *testing.T) {
|
||||||
underlying := errors.New("base")
|
underlying := errors.New("base")
|
||||||
err := Wrap(underlying, "handler.Process", "processing failed")
|
err := Wrap(underlying, "handler.Process", "processing failed")
|
||||||
|
|
@ -124,41 +95,6 @@ func TestWrap_PreservesCode_Good(t *testing.T) {
|
||||||
assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]")
|
assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrap_PreservesCode_FromNestedErrWithEmptyOuterCode_Good(t *testing.T) {
|
|
||||||
inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed")
|
|
||||||
mid := &Err{Op: "mid.Op", Msg: "mid failed", Err: inner}
|
|
||||||
|
|
||||||
outer := Wrap(mid, "outer.Op", "outer context")
|
|
||||||
|
|
||||||
assert.NotNil(t, outer)
|
|
||||||
assert.Equal(t, "VALIDATION_ERROR", ErrCode(outer))
|
|
||||||
assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrap_PreservesRecovery_Good(t *testing.T) {
|
|
||||||
retryAfter := 15 * time.Second
|
|
||||||
inner := &Err{Msg: "inner", Retryable: true, RetryAfter: &retryAfter, NextAction: "inspect input"}
|
|
||||||
|
|
||||||
outer := Wrap(inner, "outer.Op", "outer context")
|
|
||||||
|
|
||||||
assert.NotNil(t, outer)
|
|
||||||
var logErr *Err
|
|
||||||
assert.True(t, As(outer, &logErr))
|
|
||||||
assert.True(t, logErr.Retryable)
|
|
||||||
if assert.NotNil(t, logErr.RetryAfter) {
|
|
||||||
assert.Equal(t, retryAfter, *logErr.RetryAfter)
|
|
||||||
}
|
|
||||||
assert.Equal(t, "inspect input", logErr.NextAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrap_PreservesCode_FromNestedChain_Good(t *testing.T) {
|
|
||||||
root := WrapCode(errors.New("base"), "CHAIN_ERROR", "inner", "inner failed")
|
|
||||||
wrapped := Wrap(fmt.Errorf("mid layer: %w", root), "outer", "outer context")
|
|
||||||
|
|
||||||
assert.Equal(t, "CHAIN_ERROR", ErrCode(wrapped))
|
|
||||||
assert.Contains(t, wrapped.Error(), "[CHAIN_ERROR]")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrap_NilError_Good(t *testing.T) {
|
func TestWrap_NilError_Good(t *testing.T) {
|
||||||
err := Wrap(nil, "op", "msg")
|
err := Wrap(nil, "op", "msg")
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
|
|
@ -176,41 +112,6 @@ func TestWrapCode_Good(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "[INVALID_INPUT]")
|
assert.Contains(t, err.Error(), "[INVALID_INPUT]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapCode_Good_EmptyCodeDoesNotInherit(t *testing.T) {
|
|
||||||
inner := WrapCode(errors.New("base"), "INNER_CODE", "inner.Op", "inner failed")
|
|
||||||
|
|
||||||
outer := WrapCode(inner, "", "outer.Op", "outer failed")
|
|
||||||
|
|
||||||
var logErr *Err
|
|
||||||
assert.True(t, As(outer, &logErr))
|
|
||||||
assert.Equal(t, "", logErr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapCodeWithRecovery_Good(t *testing.T) {
|
|
||||||
retryAfter := time.Minute
|
|
||||||
err := WrapCodeWithRecovery(errors.New("validation failed"), "INVALID_INPUT", "api.Validate", "bad request", true, &retryAfter, "retry with backoff")
|
|
||||||
|
|
||||||
var logErr *Err
|
|
||||||
assert.NotNil(t, err)
|
|
||||||
assert.True(t, As(err, &logErr))
|
|
||||||
assert.True(t, logErr.Retryable)
|
|
||||||
assert.NotNil(t, logErr.RetryAfter)
|
|
||||||
assert.Equal(t, retryAfter, *logErr.RetryAfter)
|
|
||||||
assert.Equal(t, "retry with backoff", logErr.NextAction)
|
|
||||||
assert.Equal(t, "INVALID_INPUT", logErr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapCodeWithRecovery_Good_EmptyCodeDoesNotInherit(t *testing.T) {
|
|
||||||
retryAfter := time.Minute
|
|
||||||
inner := WrapCodeWithRecovery(errors.New("validation failed"), "INNER_CODE", "inner.Op", "inner failed", true, &retryAfter, "retry later")
|
|
||||||
|
|
||||||
outer := WrapCodeWithRecovery(inner, "", "outer.Op", "outer failed", true, &retryAfter, "retry later")
|
|
||||||
|
|
||||||
var logErr *Err
|
|
||||||
assert.True(t, As(outer, &logErr))
|
|
||||||
assert.Equal(t, "", logErr.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrapCode_Good_NilError(t *testing.T) {
|
func TestWrapCode_Good_NilError(t *testing.T) {
|
||||||
// WrapCode with nil error but with code still creates an error
|
// WrapCode with nil error but with code still creates an error
|
||||||
err := WrapCode(nil, "CODE", "op", "msg")
|
err := WrapCode(nil, "CODE", "op", "msg")
|
||||||
|
|
@ -232,19 +133,6 @@ func TestNewCode_Good(t *testing.T) {
|
||||||
assert.Nil(t, logErr.Err)
|
assert.Nil(t, logErr.Err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewCodeWithRecovery_Good(t *testing.T) {
|
|
||||||
retryAfter := 2 * time.Minute
|
|
||||||
err := NewCodeWithRecovery("NOT_FOUND", "resource not found", false, &retryAfter, "contact support")
|
|
||||||
|
|
||||||
var logErr *Err
|
|
||||||
assert.NotNil(t, err)
|
|
||||||
assert.True(t, As(err, &logErr))
|
|
||||||
assert.False(t, logErr.Retryable)
|
|
||||||
assert.NotNil(t, logErr.RetryAfter)
|
|
||||||
assert.Equal(t, retryAfter, *logErr.RetryAfter)
|
|
||||||
assert.Equal(t, "contact support", logErr.NextAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Standard Library Wrapper Tests ---
|
// --- Standard Library Wrapper Tests ---
|
||||||
|
|
||||||
func TestIs_Good(t *testing.T) {
|
func TestIs_Good(t *testing.T) {
|
||||||
|
|
@ -309,42 +197,6 @@ func TestErrCode_Good_Nil(t *testing.T) {
|
||||||
assert.Equal(t, "", ErrCode(nil))
|
assert.Equal(t, "", ErrCode(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRetryAfter_Good(t *testing.T) {
|
|
||||||
retryAfter := 42 * time.Second
|
|
||||||
err := &Err{Msg: "typed", RetryAfter: &retryAfter}
|
|
||||||
|
|
||||||
got, ok := RetryAfter(err)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, retryAfter, *got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryAfter_Good_NestedChain(t *testing.T) {
|
|
||||||
retryAfter := 42 * time.Second
|
|
||||||
inner := &Err{Msg: "typed", RetryAfter: &retryAfter}
|
|
||||||
outer := &Err{Msg: "outer", Err: inner}
|
|
||||||
|
|
||||||
got, ok := RetryAfter(outer)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, retryAfter, *got)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsRetryable_Good(t *testing.T) {
|
|
||||||
err := &Err{Msg: "typed", Retryable: true}
|
|
||||||
assert.True(t, IsRetryable(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecoveryAction_Good(t *testing.T) {
|
|
||||||
err := &Err{Msg: "typed", NextAction: "inspect"}
|
|
||||||
assert.Equal(t, "inspect", RecoveryAction(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRecoveryAction_Good_NestedChain(t *testing.T) {
|
|
||||||
inner := &Err{Msg: "typed", NextAction: "inspect"}
|
|
||||||
outer := &Err{Msg: "outer", Err: inner}
|
|
||||||
|
|
||||||
assert.Equal(t, "inspect", RecoveryAction(outer))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMessage_Good(t *testing.T) {
|
func TestMessage_Good(t *testing.T) {
|
||||||
err := E("op", "the message", errors.New("base"))
|
err := E("op", "the message", errors.New("base"))
|
||||||
assert.Equal(t, "the message", Message(err))
|
assert.Equal(t, "the message", Message(err))
|
||||||
|
|
@ -401,23 +253,6 @@ func TestLogError_Good(t *testing.T) {
|
||||||
assert.Contains(t, output, "op=\"db.Connect\"")
|
assert.Contains(t, output, "op=\"db.Connect\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogError_Good_LogsOriginalErrorContext(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
|
||||||
SetDefault(logger)
|
|
||||||
defer SetDefault(New(Options{Level: LevelInfo}))
|
|
||||||
|
|
||||||
underlying := E("db.Query", "query failed", errors.New("timeout"))
|
|
||||||
err := LogError(underlying, "db.Connect", "database unavailable")
|
|
||||||
|
|
||||||
assert.NotNil(t, err)
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
assert.Contains(t, output, "op=\"db.Connect\"")
|
|
||||||
assert.Contains(t, output, "stack=\"db.Query\"")
|
|
||||||
assert.NotContains(t, output, "stack=\"db.Connect -> db.Query\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogError_Good_NilError(t *testing.T) {
|
func TestLogError_Good_NilError(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
logger := New(Options{Level: LevelDebug, Output: &buf})
|
logger := New(Options{Level: LevelDebug, Output: &buf})
|
||||||
|
|
@ -476,7 +311,7 @@ func TestMust_Ugly_Panics(t *testing.T) {
|
||||||
|
|
||||||
// Verify error was logged before panic
|
// Verify error was logged before panic
|
||||||
output := buf.String()
|
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) {
|
func TestStackTrace_Good(t *testing.T) {
|
||||||
|
|
@ -493,18 +328,18 @@ func TestStackTrace_Good(t *testing.T) {
|
||||||
assert.Equal(t, "op3 -> op2 -> op1", formatted)
|
assert.Equal(t, "op3 -> op2 -> op1", formatted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStackTrace_Bad_PlainError(t *testing.T) {
|
func TestStackTrace_PlainError(t *testing.T) {
|
||||||
err := errors.New("plain error")
|
err := errors.New("plain error")
|
||||||
assert.Empty(t, StackTrace(err))
|
assert.Empty(t, StackTrace(err))
|
||||||
assert.Empty(t, FormatStackTrace(err))
|
assert.Empty(t, FormatStackTrace(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStackTrace_Bad_Nil(t *testing.T) {
|
func TestStackTrace_Nil(t *testing.T) {
|
||||||
assert.Empty(t, StackTrace(nil))
|
assert.Empty(t, StackTrace(nil))
|
||||||
assert.Empty(t, FormatStackTrace(nil))
|
assert.Empty(t, FormatStackTrace(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStackTrace_Bad_NoOp(t *testing.T) {
|
func TestStackTrace_NoOp(t *testing.T) {
|
||||||
err := &Err{Msg: "no op"}
|
err := &Err{Msg: "no op"}
|
||||||
assert.Empty(t, StackTrace(err))
|
assert.Empty(t, StackTrace(err))
|
||||||
assert.Empty(t, FormatStackTrace(err))
|
assert.Empty(t, FormatStackTrace(err))
|
||||||
|
|
|
||||||
9
go.mod
9
go.mod
|
|
@ -2,13 +2,14 @@ module dappco.re/go/core/log
|
||||||
|
|
||||||
go 1.26.0
|
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 (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
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/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
|
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/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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
|
|
||||||
266
log.go
266
log.go
|
|
@ -6,12 +6,12 @@
|
||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core"
|
||||||
"fmt"
|
"fmt"
|
||||||
goio "io"
|
goio "io"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -33,19 +33,6 @@ const (
|
||||||
LevelDebug
|
LevelDebug
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
defaultRotationMaxSize = 100
|
|
||||||
defaultRotationMaxAge = 28
|
|
||||||
defaultRotationMaxBackups = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
func normaliseLevel(level Level) Level {
|
|
||||||
if level < LevelQuiet || level > LevelDebug {
|
|
||||||
return LevelInfo
|
|
||||||
}
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the level name.
|
// String returns the level name.
|
||||||
func (l Level) String() string {
|
func (l Level) String() string {
|
||||||
switch l {
|
switch l {
|
||||||
|
|
@ -73,18 +60,13 @@ type Logger struct {
|
||||||
// RedactKeys is a list of keys whose values should be masked in logs.
|
// RedactKeys is a list of keys whose values should be masked in logs.
|
||||||
redactKeys []string
|
redactKeys []string
|
||||||
|
|
||||||
// StyleTimestamp formats the rendered timestamp prefix.
|
// Style functions for formatting (can be overridden)
|
||||||
StyleTimestamp func(string) string
|
StyleTimestamp func(string) string
|
||||||
// StyleDebug formats the debug level prefix.
|
StyleDebug func(string) string
|
||||||
StyleDebug func(string) string
|
StyleInfo func(string) string
|
||||||
// StyleInfo formats the info level prefix.
|
StyleWarn func(string) string
|
||||||
StyleInfo func(string) string
|
StyleError func(string) string
|
||||||
// StyleWarn formats the warning level prefix.
|
StyleSecurity func(string) string
|
||||||
StyleWarn func(string) string
|
|
||||||
// StyleError formats the error level prefix.
|
|
||||||
StyleError func(string) string
|
|
||||||
// StyleSecurity formats the security event prefix.
|
|
||||||
StyleSecurity func(string) string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotationOptions defines the log rotation and retention policy.
|
// RotationOptions defines the log rotation and retention policy.
|
||||||
|
|
@ -112,7 +94,6 @@ type RotationOptions struct {
|
||||||
|
|
||||||
// Options configures a Logger.
|
// Options configures a Logger.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// Level controls which messages are emitted.
|
|
||||||
Level Level
|
Level Level
|
||||||
// Output is the destination for log messages. If Rotation is provided,
|
// Output is the destination for log messages. If Rotation is provided,
|
||||||
// Output is ignored and logs are written to the rotating file instead.
|
// Output is ignored and logs are written to the rotating file instead.
|
||||||
|
|
@ -128,25 +109,17 @@ type Options struct {
|
||||||
var RotationWriterFactory func(RotationOptions) goio.WriteCloser
|
var RotationWriterFactory func(RotationOptions) goio.WriteCloser
|
||||||
|
|
||||||
// New creates a new Logger with the given options.
|
// New creates a new Logger with the given options.
|
||||||
//
|
|
||||||
// logger := log.New(log.Options{
|
|
||||||
// Level: log.LevelInfo,
|
|
||||||
// Output: os.Stdout,
|
|
||||||
// RedactKeys: []string{"password", "token"},
|
|
||||||
// })
|
|
||||||
func New(opts Options) *Logger {
|
func New(opts Options) *Logger {
|
||||||
level := normaliseLevel(opts.Level)
|
|
||||||
|
|
||||||
output := opts.Output
|
output := opts.Output
|
||||||
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
|
if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil {
|
||||||
output = RotationWriterFactory(normaliseRotationOptions(*opts.Rotation))
|
output = RotationWriterFactory(*opts.Rotation)
|
||||||
}
|
}
|
||||||
if output == nil {
|
if output == nil {
|
||||||
output = os.Stderr
|
output = os.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Logger{
|
return &Logger{
|
||||||
level: level,
|
level: opts.Level,
|
||||||
output: output,
|
output: output,
|
||||||
redactKeys: slices.Clone(opts.RedactKeys),
|
redactKeys: slices.Clone(opts.RedactKeys),
|
||||||
StyleTimestamp: identity,
|
StyleTimestamp: identity,
|
||||||
|
|
@ -158,40 +131,16 @@ func New(opts Options) *Logger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normaliseRotationOptions(opts RotationOptions) RotationOptions {
|
|
||||||
if opts.MaxSize <= 0 {
|
|
||||||
opts.MaxSize = defaultRotationMaxSize
|
|
||||||
}
|
|
||||||
if opts.MaxAge == 0 {
|
|
||||||
opts.MaxAge = defaultRotationMaxAge
|
|
||||||
}
|
|
||||||
if opts.MaxBackups <= 0 {
|
|
||||||
opts.MaxBackups = defaultRotationMaxBackups
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
|
|
||||||
func identity(s string) string { return s }
|
func identity(s string) string { return s }
|
||||||
|
|
||||||
func safeStyle(style func(string) string) func(string) string {
|
|
||||||
if style == nil {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
return style
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLevel changes the log level.
|
// SetLevel changes the log level.
|
||||||
//
|
|
||||||
// logger.SetLevel(log.LevelDebug)
|
|
||||||
func (l *Logger) SetLevel(level Level) {
|
func (l *Logger) SetLevel(level Level) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
l.level = normaliseLevel(level)
|
l.level = level
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level returns the current log level.
|
// Level returns the current log level.
|
||||||
//
|
|
||||||
// current := logger.Level()
|
|
||||||
func (l *Logger) Level() Level {
|
func (l *Logger) Level() Level {
|
||||||
l.mu.RLock()
|
l.mu.RLock()
|
||||||
defer l.mu.RUnlock()
|
defer l.mu.RUnlock()
|
||||||
|
|
@ -199,20 +148,13 @@ func (l *Logger) Level() Level {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetOutput changes the output writer.
|
// SetOutput changes the output writer.
|
||||||
//
|
|
||||||
// logger.SetOutput(os.Stdout)
|
|
||||||
func (l *Logger) SetOutput(w goio.Writer) {
|
func (l *Logger) SetOutput(w goio.Writer) {
|
||||||
if w == nil {
|
|
||||||
w = os.Stderr
|
|
||||||
}
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
l.output = w
|
l.output = w
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRedactKeys sets the keys to be redacted.
|
// SetRedactKeys sets the keys to be redacted.
|
||||||
//
|
|
||||||
// logger.SetRedactKeys("password", "token", "secret")
|
|
||||||
func (l *Logger) SetRedactKeys(keys ...string) {
|
func (l *Logger) SetRedactKeys(keys ...string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
l.redactKeys = slices.Clone(keys)
|
l.redactKeys = slices.Clone(keys)
|
||||||
|
|
@ -226,67 +168,47 @@ func (l *Logger) shouldLog(level Level) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
||||||
_ = level
|
|
||||||
l.mu.RLock()
|
l.mu.RLock()
|
||||||
output := l.output
|
output := l.output
|
||||||
styleTimestamp := l.StyleTimestamp
|
styleTimestamp := l.StyleTimestamp
|
||||||
redactKeys := l.redactKeys
|
redactKeys := l.redactKeys
|
||||||
l.mu.RUnlock()
|
l.mu.RUnlock()
|
||||||
|
|
||||||
if styleTimestamp == nil {
|
|
||||||
styleTimestamp = identity
|
|
||||||
}
|
|
||||||
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
|
timestamp := styleTimestamp(time.Now().Format("15:04:05"))
|
||||||
|
|
||||||
existing := make(map[string]struct{}, len(keyvals)/2+2)
|
|
||||||
for i := 0; i < len(keyvals); i += 2 {
|
|
||||||
if key, ok := keyvals[i].(string); ok {
|
|
||||||
existing[key] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically extract context from error if present in keyvals
|
// Automatically extract context from error if present in keyvals
|
||||||
origLen := len(keyvals)
|
origLen := len(keyvals)
|
||||||
for i := 0; i < origLen; i += 2 {
|
for i := 0; i < origLen; i += 2 {
|
||||||
if i+1 >= origLen {
|
if i+1 < origLen {
|
||||||
continue
|
if err, ok := keyvals[i+1].(error); ok {
|
||||||
}
|
if op := Op(err); op != "" {
|
||||||
err, ok := keyvals[i+1].(error)
|
// Check if op is already in keyvals
|
||||||
if !ok {
|
hasOp := false
|
||||||
continue
|
for j := 0; j < len(keyvals); j += 2 {
|
||||||
}
|
if k, ok := keyvals[j].(string); ok && k == "op" {
|
||||||
var logErr *Err
|
hasOp = true
|
||||||
if As(err, &logErr) {
|
break
|
||||||
if _, hasRetryable := existing["retryable"]; !hasRetryable {
|
}
|
||||||
existing["retryable"] = struct{}{}
|
}
|
||||||
keyvals = append(keyvals, "retryable", retryableHint(err))
|
if !hasOp {
|
||||||
}
|
keyvals = append(keyvals, "op", op)
|
||||||
if retryAfter, ok := RetryAfter(err); ok {
|
}
|
||||||
if _, hasRetryAfter := existing["retry_after_seconds"]; !hasRetryAfter {
|
|
||||||
existing["retry_after_seconds"] = struct{}{}
|
|
||||||
keyvals = append(keyvals, "retry_after_seconds", retryAfter.Seconds())
|
|
||||||
}
|
}
|
||||||
}
|
if stack := FormatStackTrace(err); stack != "" {
|
||||||
if nextAction := RecoveryAction(err); nextAction != "" {
|
// Check if stack is already in keyvals
|
||||||
if _, hasNextAction := existing["next_action"]; !hasNextAction {
|
hasStack := false
|
||||||
existing["next_action"] = struct{}{}
|
for j := 0; j < len(keyvals); j += 2 {
|
||||||
keyvals = append(keyvals, "next_action", nextAction)
|
if k, ok := keyvals[j].(string); ok && k == "stack" {
|
||||||
|
hasStack = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasStack {
|
||||||
|
keyvals = append(keyvals, "stack", stack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if op := Op(err); op != "" {
|
|
||||||
if _, hasOp := existing["op"]; !hasOp {
|
|
||||||
existing["op"] = struct{}{}
|
|
||||||
keyvals = append(keyvals, "op", op)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stack := FormatStackTrace(err); stack != "" {
|
|
||||||
if _, hasStack := existing["stack"]; !hasStack {
|
|
||||||
existing["stack"] = struct{}{}
|
|
||||||
keyvals = append(keyvals, "stack", stack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format key-value pairs
|
// Format key-value pairs
|
||||||
|
|
@ -297,201 +219,125 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
kvStr += " "
|
kvStr += " "
|
||||||
}
|
}
|
||||||
key := normaliseLogText(fmt.Sprintf("%v", keyvals[i]))
|
key := keyvals[i]
|
||||||
var val any
|
var val any
|
||||||
if i+1 < len(keyvals) {
|
if i+1 < len(keyvals) {
|
||||||
val = keyvals[i+1]
|
val = keyvals[i+1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redaction logic
|
// Redaction logic
|
||||||
if shouldRedact(key, redactKeys) {
|
keyStr := core.Sprintf("%v", key)
|
||||||
|
if slices.Contains(redactKeys, keyStr) {
|
||||||
val = "[REDACTED]"
|
val = "[REDACTED]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secure formatting to prevent log injection
|
// Secure formatting to prevent log injection
|
||||||
if s, ok := val.(string); ok {
|
if s, ok := val.(string); ok {
|
||||||
kvStr += fmt.Sprintf("%s=%q", key, s)
|
kvStr += core.Sprintf("%v=%q", key, s)
|
||||||
} else {
|
} else {
|
||||||
kvStr += fmt.Sprintf("%s=%v", key, normaliseLogText(fmt.Sprintf("%v", val)))
|
kvStr += core.Sprintf("%v=%v", key, val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, normaliseLogText(msg), kvStr)
|
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a debug message with optional key-value pairs.
|
// 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) {
|
func (l *Logger) Debug(msg string, keyvals ...any) {
|
||||||
if l.shouldLog(LevelDebug) {
|
if l.shouldLog(LevelDebug) {
|
||||||
l.mu.RLock()
|
l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...)
|
||||||
style := safeStyle(l.StyleDebug)
|
|
||||||
l.mu.RUnlock()
|
|
||||||
l.log(LevelDebug, style("[DBG]"), msg, keyvals...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs an info message with optional key-value pairs.
|
// Info logs an info message with optional key-value pairs.
|
||||||
//
|
|
||||||
// logger.Info("server started", "port", 8080)
|
|
||||||
func (l *Logger) Info(msg string, keyvals ...any) {
|
func (l *Logger) Info(msg string, keyvals ...any) {
|
||||||
if l.shouldLog(LevelInfo) {
|
if l.shouldLog(LevelInfo) {
|
||||||
l.mu.RLock()
|
l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...)
|
||||||
style := safeStyle(l.StyleInfo)
|
|
||||||
l.mu.RUnlock()
|
|
||||||
l.log(LevelInfo, style("[INF]"), msg, keyvals...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs a warning message with optional key-value pairs.
|
// 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) {
|
func (l *Logger) Warn(msg string, keyvals ...any) {
|
||||||
if l.shouldLog(LevelWarn) {
|
if l.shouldLog(LevelWarn) {
|
||||||
l.mu.RLock()
|
l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...)
|
||||||
style := safeStyle(l.StyleWarn)
|
|
||||||
l.mu.RUnlock()
|
|
||||||
l.log(LevelWarn, style("[WRN]"), msg, keyvals...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs an error message with optional key-value pairs.
|
// 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) {
|
func (l *Logger) Error(msg string, keyvals ...any) {
|
||||||
if l.shouldLog(LevelError) {
|
if l.shouldLog(LevelError) {
|
||||||
l.mu.RLock()
|
l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...)
|
||||||
style := safeStyle(l.StyleError)
|
|
||||||
l.mu.RUnlock()
|
|
||||||
l.log(LevelError, style("[ERR]"), msg, keyvals...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security logs a security event with optional key-value pairs.
|
// Security logs a security event with optional key-value pairs.
|
||||||
// It uses LevelError to ensure security events are visible even in restrictive
|
// It uses LevelError to ensure security events are visible even in restrictive
|
||||||
// log configurations.
|
// log configurations.
|
||||||
//
|
|
||||||
// logger.Security("brute force detected", "ip", remoteAddr, "attempts", 50)
|
|
||||||
func (l *Logger) Security(msg string, keyvals ...any) {
|
func (l *Logger) Security(msg string, keyvals ...any) {
|
||||||
if l.shouldLog(LevelError) {
|
if l.shouldLog(LevelError) {
|
||||||
l.mu.RLock()
|
l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...)
|
||||||
style := safeStyle(l.StyleSecurity)
|
|
||||||
l.mu.RUnlock()
|
|
||||||
l.log(LevelError, style("[SEC]"), msg, keyvals...)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username returns the current system username.
|
// Username returns the current system username.
|
||||||
// It uses os/user for reliability and falls back to environment variables.
|
// It uses os/user for reliability and falls back to environment variables.
|
||||||
//
|
|
||||||
// user := log.Username()
|
|
||||||
func Username() string {
|
func Username() string {
|
||||||
if u, err := user.Current(); err == nil {
|
if u, err := user.Current(); err == nil {
|
||||||
return u.Username
|
return u.Username
|
||||||
}
|
}
|
||||||
// Fallback for environments where user lookup might fail
|
// Fallback for environments where user lookup might fail
|
||||||
if u := os.Getenv("USER"); u != "" {
|
if u := core.Env("USER"); u != "" {
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
if u := os.Getenv("USERNAME"); u != "" {
|
return core.Env("USERNAME")
|
||||||
return u
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
var logTextCleaner = strings.NewReplacer(
|
|
||||||
"\r", "\\r",
|
|
||||||
"\n", "\\n",
|
|
||||||
"\t", "\\t",
|
|
||||||
)
|
|
||||||
|
|
||||||
func normaliseLogText(text string) string {
|
|
||||||
return logTextCleaner.Replace(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Default logger ---
|
// --- Default logger ---
|
||||||
|
|
||||||
var defaultLogger = New(Options{Level: LevelInfo})
|
var defaultLogger = New(Options{Level: LevelInfo})
|
||||||
var defaultLoggerMu sync.RWMutex
|
|
||||||
|
|
||||||
// Default returns the default logger.
|
// Default returns the default logger.
|
||||||
//
|
|
||||||
// logger := log.Default()
|
|
||||||
func Default() *Logger {
|
func Default() *Logger {
|
||||||
defaultLoggerMu.RLock()
|
|
||||||
defer defaultLoggerMu.RUnlock()
|
|
||||||
return defaultLogger
|
return defaultLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDefault sets the default logger.
|
// SetDefault sets the default logger.
|
||||||
// Passing nil is ignored to preserve the current default logger.
|
|
||||||
//
|
|
||||||
// log.SetDefault(customLogger)
|
|
||||||
func SetDefault(l *Logger) {
|
func SetDefault(l *Logger) {
|
||||||
if l == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultLoggerMu.Lock()
|
|
||||||
defaultLogger = l
|
defaultLogger = l
|
||||||
defaultLoggerMu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLevel sets the default logger's level.
|
// SetLevel sets the default logger's level.
|
||||||
//
|
|
||||||
// log.SetLevel(log.LevelDebug)
|
|
||||||
func SetLevel(level Level) {
|
func SetLevel(level Level) {
|
||||||
Default().SetLevel(level)
|
defaultLogger.SetLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRedactKeys sets the default logger's redaction keys.
|
// SetRedactKeys sets the default logger's redaction keys.
|
||||||
//
|
|
||||||
// log.SetRedactKeys("password", "token")
|
|
||||||
func SetRedactKeys(keys ...string) {
|
func SetRedactKeys(keys ...string) {
|
||||||
Default().SetRedactKeys(keys...)
|
defaultLogger.SetRedactKeys(keys...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs to the default logger.
|
// Debug logs to the default logger.
|
||||||
//
|
|
||||||
// log.Debug("query started", "sql", query)
|
|
||||||
func Debug(msg string, keyvals ...any) {
|
func Debug(msg string, keyvals ...any) {
|
||||||
Default().Debug(msg, keyvals...)
|
defaultLogger.Debug(msg, keyvals...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs to the default logger.
|
// Info logs to the default logger.
|
||||||
//
|
|
||||||
// log.Info("server ready", "port", 8080)
|
|
||||||
func Info(msg string, keyvals ...any) {
|
func Info(msg string, keyvals ...any) {
|
||||||
Default().Info(msg, keyvals...)
|
defaultLogger.Info(msg, keyvals...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs to the default logger.
|
// Warn logs to the default logger.
|
||||||
//
|
|
||||||
// log.Warn("retrying request", "attempt", 2)
|
|
||||||
func Warn(msg string, keyvals ...any) {
|
func Warn(msg string, keyvals ...any) {
|
||||||
Default().Warn(msg, keyvals...)
|
defaultLogger.Warn(msg, keyvals...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs to the default logger.
|
// Error logs to the default logger.
|
||||||
//
|
|
||||||
// log.Error("request failed", "err", err)
|
|
||||||
func Error(msg string, keyvals ...any) {
|
func Error(msg string, keyvals ...any) {
|
||||||
Default().Error(msg, keyvals...)
|
defaultLogger.Error(msg, keyvals...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security logs to the default logger.
|
// Security logs to the default logger.
|
||||||
//
|
|
||||||
// log.Security("suspicious login", "ip", remoteAddr)
|
|
||||||
func Security(msg string, keyvals ...any) {
|
func Security(msg string, keyvals ...any) {
|
||||||
Default().Security(msg, keyvals...)
|
defaultLogger.Security(msg, keyvals...)
|
||||||
}
|
|
||||||
|
|
||||||
func shouldRedact(key any, redactKeys []string) bool {
|
|
||||||
keyStr := fmt.Sprintf("%v", key)
|
|
||||||
for _, redactKey := range redactKeys {
|
|
||||||
if redactKey == keyStr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
260
log_test.go
260
log_test.go
|
|
@ -2,12 +2,9 @@ package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"dappco.re/go/core"
|
||||||
goio "io"
|
goio "io"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// nopWriteCloser wraps a writer with a no-op Close for testing rotation.
|
// nopWriteCloser wraps a writer with a no-op Close for testing rotation.
|
||||||
|
|
@ -15,7 +12,11 @@ type nopWriteCloser struct{ goio.Writer }
|
||||||
|
|
||||||
func (nopWriteCloser) Close() error { return nil }
|
func (nopWriteCloser) Close() error { return nil }
|
||||||
|
|
||||||
func TestLogger_Levels_Good(t *testing.T) {
|
func substringCount(s, substr string) int {
|
||||||
|
return len(core.Split(s, substr)) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogger_Levels(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
level Level
|
level Level
|
||||||
|
|
@ -65,25 +66,25 @@ func TestLogger_Levels_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_KeyValues_Good(t *testing.T) {
|
func TestLogger_KeyValues(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{Level: LevelDebug, Output: &buf})
|
l := New(Options{Level: LevelDebug, Output: &buf})
|
||||||
|
|
||||||
l.Info("test message", "key1", "value1", "key2", 42)
|
l.Info("test message", "key1", "value1", "key2", 42)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "test message") {
|
if !core.Contains(output, "test message") {
|
||||||
t.Error("expected message in output")
|
t.Error("expected message in output")
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "key1=\"value1\"") {
|
if !core.Contains(output, "key1=\"value1\"") {
|
||||||
t.Errorf("expected key1=\"value1\" in output, got %q", output)
|
t.Errorf("expected key1=\"value1\" in output, got %q", output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "key2=42") {
|
if !core.Contains(output, "key2=42") {
|
||||||
t.Error("expected key2=42 in output")
|
t.Error("expected key2=42 in output")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_ErrorContext_Good(t *testing.T) {
|
func TestLogger_ErrorContext(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{Output: &buf, Level: LevelInfo})
|
l := New(Options{Output: &buf, Level: LevelInfo})
|
||||||
|
|
||||||
|
|
@ -93,62 +94,15 @@ func TestLogger_ErrorContext_Good(t *testing.T) {
|
||||||
l.Error("something failed", "err", err)
|
l.Error("something failed", "err", err)
|
||||||
|
|
||||||
got := buf.String()
|
got := buf.String()
|
||||||
if !strings.Contains(got, "op=\"outer.Op\"") {
|
if !core.Contains(got, "op=\"outer.Op\"") {
|
||||||
t.Errorf("expected output to contain op=\"outer.Op\", got %q", got)
|
t.Errorf("expected output to contain op=\"outer.Op\", got %q", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "stack=\"outer.Op -> test.Op\"") {
|
if !core.Contains(got, "stack=\"outer.Op -> test.Op\"") {
|
||||||
t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got)
|
t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_ErrorContextIncludesRecovery_Good(t *testing.T) {
|
func TestLogger_Redaction(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Output: &buf, Level: LevelInfo})
|
|
||||||
retryAfter := 45 * time.Second
|
|
||||||
|
|
||||||
err := EWithRecovery("retryable.Op", "temporary failure", errors.New("temporary failure"), true, &retryAfter, "retry with backoff")
|
|
||||||
l.Error("request failed", "err", err)
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, "retryable=true") {
|
|
||||||
t.Errorf("expected output to contain retryable=true, got %q", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "retry_after_seconds=45") {
|
|
||||||
t.Errorf("expected output to contain retry_after_seconds=45, got %q", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "next_action=\"retry with backoff\"") {
|
|
||||||
t.Errorf("expected output to contain next_action=\"retry with backoff\", got %q", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_ErrorContextIncludesNestedRecovery_Good(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Output: &buf, Level: LevelInfo})
|
|
||||||
retryAfter := 30 * time.Second
|
|
||||||
|
|
||||||
inner := &Err{
|
|
||||||
Msg: "inner failure",
|
|
||||||
Retryable: true,
|
|
||||||
RetryAfter: &retryAfter,
|
|
||||||
NextAction: "retry later",
|
|
||||||
}
|
|
||||||
outer := &Err{Msg: "outer failure", Err: inner}
|
|
||||||
|
|
||||||
l.Error("request failed", "err", outer)
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, "retryable=true") {
|
|
||||||
t.Errorf("expected output to contain retryable=true, got %q", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "retry_after_seconds=30") {
|
|
||||||
t.Errorf("expected output to contain retry_after_seconds=30, got %q", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "next_action=\"retry later\"") {
|
|
||||||
t.Errorf("expected output to contain next_action=\"retry later\", got %q", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_Redaction_Good(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{
|
l := New(Options{
|
||||||
Level: LevelInfo,
|
Level: LevelInfo,
|
||||||
|
|
@ -159,88 +113,35 @@ func TestLogger_Redaction_Good(t *testing.T) {
|
||||||
l.Info("login", "user", "admin", "password", "secret123", "token", "abc-123")
|
l.Info("login", "user", "admin", "password", "secret123", "token", "abc-123")
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "user=\"admin\"") {
|
if !core.Contains(output, "user=\"admin\"") {
|
||||||
t.Error("expected user=\"admin\"")
|
t.Error("expected user=\"admin\"")
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "password=\"[REDACTED]\"") {
|
if !core.Contains(output, "password=\"[REDACTED]\"") {
|
||||||
t.Errorf("expected password=\"[REDACTED]\", got %q", output)
|
t.Errorf("expected password=\"[REDACTED]\", got %q", output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "token=\"[REDACTED]\"") {
|
if !core.Contains(output, "token=\"[REDACTED]\"") {
|
||||||
t.Errorf("expected token=\"[REDACTED]\", got %q", output)
|
t.Errorf("expected token=\"[REDACTED]\", got %q", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_Redaction_Bad_CaseMismatchNotRedacted(t *testing.T) {
|
func TestLogger_InjectionPrevention(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{
|
|
||||||
Level: LevelInfo,
|
|
||||||
Output: &buf,
|
|
||||||
RedactKeys: []string{"password"},
|
|
||||||
})
|
|
||||||
|
|
||||||
l.Info("login", "PASSWORD", "secret123")
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
if !strings.Contains(output, "PASSWORD=\"secret123\"") {
|
|
||||||
t.Errorf("expected case-mismatched key to remain visible, got %q", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_InjectionPrevention_Good(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||||
|
|
||||||
l.Info("message", "key", "value\n[SEC] injected message")
|
l.Info("message", "key", "value\n[SEC] injected message")
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "key=\"value\\n[SEC] injected message\"") {
|
if !core.Contains(output, "key=\"value\\n[SEC] injected message\"") {
|
||||||
t.Errorf("expected escaped newline, got %q", output)
|
t.Errorf("expected escaped newline, got %q", output)
|
||||||
}
|
}
|
||||||
// Ensure it's still a single line (excluding trailing newline)
|
// Ensure it's still a single line (excluding trailing newline)
|
||||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
lines := core.Split(core.Trim(output), "\n")
|
||||||
if len(lines) != 1 {
|
if len(lines) != 1 {
|
||||||
t.Errorf("expected 1 line, got %d", len(lines))
|
t.Errorf("expected 1 line, got %d", len(lines))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_KeySanitization_Good(t *testing.T) {
|
func TestLogger_SetLevel(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
|
||||||
|
|
||||||
l.Info("message", "key\nwith newline", "value\nwith newline")
|
|
||||||
output := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(output, "key\\nwith newline") {
|
|
||||||
t.Errorf("expected sanitized key, got %q", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "value\\nwith newline") {
|
|
||||||
t.Errorf("expected sanitized value, got %q", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
||||||
if len(lines) != 1 {
|
|
||||||
t.Errorf("expected 1 line, got %d", len(lines))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_MessageSanitization_Good(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
|
||||||
|
|
||||||
l.Info("message\nwith\tcontrol\rchars")
|
|
||||||
output := buf.String()
|
|
||||||
|
|
||||||
if !strings.Contains(output, "message\\nwith\\tcontrol\\rchars") {
|
|
||||||
t.Errorf("expected control characters to be escaped, got %q", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
|
||||||
if len(lines) != 1 {
|
|
||||||
t.Errorf("expected 1 line, got %d", len(lines))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_SetLevel_Good(t *testing.T) {
|
|
||||||
l := New(Options{Level: LevelInfo})
|
l := New(Options{Level: LevelInfo})
|
||||||
|
|
||||||
if l.Level() != LevelInfo {
|
if l.Level() != LevelInfo {
|
||||||
|
|
@ -251,14 +152,9 @@ func TestLogger_SetLevel_Good(t *testing.T) {
|
||||||
if l.Level() != LevelDebug {
|
if l.Level() != LevelDebug {
|
||||||
t.Error("expected level to be Debug after SetLevel")
|
t.Error("expected level to be Debug after SetLevel")
|
||||||
}
|
}
|
||||||
|
|
||||||
l.SetLevel(99)
|
|
||||||
if l.Level() != LevelInfo {
|
|
||||||
t.Errorf("expected invalid level to default back to info, got %v", l.Level())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLevel_String_Good(t *testing.T) {
|
func TestLevel_String(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
level Level
|
level Level
|
||||||
expected string
|
expected string
|
||||||
|
|
@ -280,20 +176,20 @@ func TestLevel_String_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_Security_Good(t *testing.T) {
|
func TestLogger_Security(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{Level: LevelError, Output: &buf})
|
l := New(Options{Level: LevelError, Output: &buf})
|
||||||
|
|
||||||
l.Security("unauthorized access", "user", "admin")
|
l.Security("unauthorized access", "user", "admin")
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "[SEC]") {
|
if !core.Contains(output, "[SEC]") {
|
||||||
t.Error("expected [SEC] prefix in security log")
|
t.Error("expected [SEC] prefix in security log")
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "unauthorized access") {
|
if !core.Contains(output, "unauthorized access") {
|
||||||
t.Error("expected message in security log")
|
t.Error("expected message in security log")
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "user=\"admin\"") {
|
if !core.Contains(output, "user=\"admin\"") {
|
||||||
t.Error("expected context in security log")
|
t.Error("expected context in security log")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,31 +210,20 @@ func TestLogger_SetOutput_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_SetOutput_Bad_NilFallsBackToStderr(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
|
||||||
|
|
||||||
l.SetOutput(nil)
|
|
||||||
|
|
||||||
if l.output != os.Stderr {
|
|
||||||
t.Errorf("expected nil output to fallback to os.Stderr, got %T", l.output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_SetRedactKeys_Good(t *testing.T) {
|
func TestLogger_SetRedactKeys_Good(t *testing.T) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
l := New(Options{Level: LevelInfo, Output: &buf})
|
l := New(Options{Level: LevelInfo, Output: &buf})
|
||||||
|
|
||||||
// No redaction initially
|
// No redaction initially
|
||||||
l.Info("msg", "secret", "visible")
|
l.Info("msg", "secret", "visible")
|
||||||
if !strings.Contains(buf.String(), "secret=\"visible\"") {
|
if !core.Contains(buf.String(), "secret=\"visible\"") {
|
||||||
t.Errorf("expected visible value, got %q", buf.String())
|
t.Errorf("expected visible value, got %q", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
l.SetRedactKeys("secret")
|
l.SetRedactKeys("secret")
|
||||||
l.Info("msg", "secret", "hidden")
|
l.Info("msg", "secret", "hidden")
|
||||||
if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") {
|
if !core.Contains(buf.String(), "secret=\"[REDACTED]\"") {
|
||||||
t.Errorf("expected redacted value, got %q", buf.String())
|
t.Errorf("expected redacted value, got %q", buf.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +235,7 @@ func TestLogger_OddKeyvals_Good(t *testing.T) {
|
||||||
// Odd number of keyvals — last key should have no value
|
// Odd number of keyvals — last key should have no value
|
||||||
l.Info("msg", "lonely_key")
|
l.Info("msg", "lonely_key")
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if !strings.Contains(output, "lonely_key=<nil>") {
|
if !core.Contains(output, "lonely_key=<nil>") {
|
||||||
t.Errorf("expected lonely_key=<nil>, got %q", output)
|
t.Errorf("expected lonely_key=<nil>, got %q", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -364,10 +249,10 @@ func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) {
|
||||||
l.Error("failed", "op", "explicit.Op", "err", err)
|
l.Error("failed", "op", "explicit.Op", "err", err)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if strings.Count(output, "op=") != 1 {
|
if substringCount(output, "op=") != 1 {
|
||||||
t.Errorf("expected exactly one op= in output, got %q", output)
|
t.Errorf("expected exactly one op= in output, got %q", output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "op=\"explicit.Op\"") {
|
if !core.Contains(output, "op=\"explicit.Op\"") {
|
||||||
t.Errorf("expected explicit op, got %q", output)
|
t.Errorf("expected explicit op, got %q", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -381,10 +266,10 @@ func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) {
|
||||||
l.Error("failed", "stack", "custom.Stack", "err", err)
|
l.Error("failed", "stack", "custom.Stack", "err", err)
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
if strings.Count(output, "stack=") != 1 {
|
if substringCount(output, "stack=") != 1 {
|
||||||
t.Errorf("expected exactly one stack= in output, got %q", output)
|
t.Errorf("expected exactly one stack= in output, got %q", output)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "stack=\"custom.Stack\"") {
|
if !core.Contains(output, "stack=\"custom.Stack\"") {
|
||||||
t.Errorf("expected custom stack, got %q", output)
|
t.Errorf("expected custom stack, got %q", output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -410,32 +295,6 @@ func TestNew_RotationFactory_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_RotationFactory_Good_DefaultRetentionValues(t *testing.T) {
|
|
||||||
original := RotationWriterFactory
|
|
||||||
defer func() { RotationWriterFactory = original }()
|
|
||||||
|
|
||||||
var captured RotationOptions
|
|
||||||
RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser {
|
|
||||||
captured = opts
|
|
||||||
return nopWriteCloser{goio.Discard}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = New(Options{
|
|
||||||
Level: LevelInfo,
|
|
||||||
Rotation: &RotationOptions{Filename: "test.log"},
|
|
||||||
})
|
|
||||||
|
|
||||||
if captured.MaxSize != defaultRotationMaxSize {
|
|
||||||
t.Errorf("expected default MaxSize=%d, got %d", defaultRotationMaxSize, captured.MaxSize)
|
|
||||||
}
|
|
||||||
if captured.MaxAge != defaultRotationMaxAge {
|
|
||||||
t.Errorf("expected default MaxAge=%d, got %d", defaultRotationMaxAge, captured.MaxAge)
|
|
||||||
}
|
|
||||||
if captured.MaxBackups != defaultRotationMaxBackups {
|
|
||||||
t.Errorf("expected default MaxBackups=%d, got %d", defaultRotationMaxBackups, captured.MaxBackups)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNew_DefaultOutput_Good(t *testing.T) {
|
func TestNew_DefaultOutput_Good(t *testing.T) {
|
||||||
// No output or rotation — should default to stderr (not nil)
|
// No output or rotation — should default to stderr (not nil)
|
||||||
l := New(Options{Level: LevelInfo})
|
l := New(Options{Level: LevelInfo})
|
||||||
|
|
@ -444,13 +303,6 @@ func TestNew_DefaultOutput_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNew_Bad_InvalidLevelDefaultsToInfo(t *testing.T) {
|
|
||||||
l := New(Options{Level: Level(99)})
|
|
||||||
if l.Level() != LevelInfo {
|
|
||||||
t.Errorf("expected invalid level to default to info, got %v", l.Level())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUsername_Good(t *testing.T) {
|
func TestUsername_Good(t *testing.T) {
|
||||||
name := Username()
|
name := Username()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
@ -484,51 +336,7 @@ func TestDefault_Good(t *testing.T) {
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} {
|
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 TestDefault_Bad_SetDefaultNilIgnored(t *testing.T) {
|
|
||||||
original := Default()
|
|
||||||
var buf bytes.Buffer
|
|
||||||
custom := New(Options{Level: LevelInfo, Output: &buf})
|
|
||||||
SetDefault(custom)
|
|
||||||
defer SetDefault(original)
|
|
||||||
|
|
||||||
SetDefault(nil)
|
|
||||||
|
|
||||||
if Default() != custom {
|
|
||||||
t.Error("expected SetDefault(nil) to preserve the current default logger")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogger_StyleHooks_Bad_NilHooksDoNotPanic(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
l := New(Options{Level: LevelDebug, Output: &buf})
|
|
||||||
l.StyleTimestamp = nil
|
|
||||||
l.StyleDebug = nil
|
|
||||||
l.StyleInfo = nil
|
|
||||||
l.StyleWarn = nil
|
|
||||||
l.StyleError = nil
|
|
||||||
l.StyleSecurity = nil
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("expected nil style hooks not to panic, got panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
l.Debug("debug")
|
|
||||||
l.Info("info")
|
|
||||||
l.Warn("warn")
|
|
||||||
l.Error("error")
|
|
||||||
l.Security("security")
|
|
||||||
|
|
||||||
output := buf.String()
|
|
||||||
for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} {
|
|
||||||
if !strings.Contains(output, tag) {
|
|
||||||
t.Errorf("expected %s in output, got %q", tag, output)
|
t.Errorf("expected %s in output, got %q", tag, output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
266
specs/RFC.md
266
specs/RFC.md
|
|
@ -1,266 +0,0 @@
|
||||||
# log
|
|
||||||
**Import:** `dappco.re/go/core/log`
|
|
||||||
**Files:** 2
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### Err
|
|
||||||
`type Err struct`
|
|
||||||
|
|
||||||
Structured error wrapper that carries operation context, a human-readable message, an optional wrapped cause, and an optional machine-readable code.
|
|
||||||
It can also carry agent-facing recovery metadata that survives wrapping and is
|
|
||||||
surfaced automatically in structured logs.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Op string`: operation name. When non-empty, `Error` prefixes the formatted message with `Op + ": "`.
|
|
||||||
- `Msg string`: human-readable message stored on the error and returned by `Message`.
|
|
||||||
- `Err error`: wrapped cause returned by `Unwrap`.
|
|
||||||
- `Code string`: optional machine-readable code. When non-empty, `Error` includes it in square brackets.
|
|
||||||
- `Retryable bool`: whether the caller can safely retry the operation.
|
|
||||||
- `RetryAfter *time.Duration`: optional retry delay hint when `Retryable` is true.
|
|
||||||
- `NextAction string`: suggested next step when the error is not directly retryable.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, `"{Op}: {Msg} [{Code}]: {Err}"`, or a cleanly collapsed form such as `"{Op}"` when no message, code, or cause is present.
|
|
||||||
- `func (e *Err) Unwrap() error`: returns `e.Err`.
|
|
||||||
|
|
||||||
### Level
|
|
||||||
`type Level int`
|
|
||||||
|
|
||||||
Logging verbosity enum used by `Logger` and `Options`.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `func (l Level) String() string`: returns `quiet`, `error`, `warn`, `info`, or `debug`. Any other value returns `unknown`.
|
|
||||||
|
|
||||||
### Logger
|
|
||||||
`type Logger struct`
|
|
||||||
|
|
||||||
Concurrency-safe structured logger. `New` clones the configured redaction keys, stores the configured level and writer, and initializes all style hooks to identity functions.
|
|
||||||
|
|
||||||
Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as `<nil>`, and any `error` value in `keyvals` can cause `op`, `stack`, `retryable`, `retry_after_seconds`, and `next_action` fields to be appended automatically if those keys were not already supplied.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `StyleTimestamp func(string) string`: transforms the rendered `HH:MM:SS` timestamp before it is written.
|
|
||||||
- `StyleDebug func(string) string`: transforms the debug prefix passed to debug log lines.
|
|
||||||
- `StyleInfo func(string) string`: transforms the info prefix passed to info log lines.
|
|
||||||
- `StyleWarn func(string) string`: transforms the warn prefix passed to warning log lines.
|
|
||||||
- `StyleError func(string) string`: transforms the error prefix passed to error log lines.
|
|
||||||
- `StyleSecurity func(string) string`: transforms the security prefix passed to security log lines.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `func (l *Logger) SetLevel(level Level)`: sets the logger’s current threshold.
|
|
||||||
- `func (l *Logger) Level() Level`: returns the logger’s current threshold.
|
|
||||||
- `func (l *Logger) SetOutput(w goio.Writer)`: replaces the writer used for future log lines.
|
|
||||||
- `func (l *Logger) SetRedactKeys(keys ...string)`: replaces the exact-match key list whose values are masked during formatting.
|
|
||||||
- `func (l *Logger) Debug(msg string, keyvals ...any)`: emits a debug line when the logger level is at least `LevelDebug`.
|
|
||||||
- `func (l *Logger) Info(msg string, keyvals ...any)`: emits an info line when the logger level is at least `LevelInfo`.
|
|
||||||
- `func (l *Logger) Warn(msg string, keyvals ...any)`: emits a warning line when the logger level is at least `LevelWarn`.
|
|
||||||
- `func (l *Logger) Error(msg string, keyvals ...any)`: emits an error line when the logger level is at least `LevelError`.
|
|
||||||
- `func (l *Logger) Security(msg string, keyvals ...any)`: emits a security line with the security style prefix and the same visibility threshold as `LevelError`.
|
|
||||||
|
|
||||||
### Options
|
|
||||||
`type Options struct`
|
|
||||||
|
|
||||||
Constructor input for `New`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Level Level`: initial logger threshold.
|
|
||||||
- `Output goio.Writer`: destination used when rotation is not selected.
|
|
||||||
- `Rotation *RotationOptions`: optional rotation configuration. `New` uses rotation only when this field is non-nil, `Rotation.Filename` is non-empty, and `RotationWriterFactory` is non-nil.
|
|
||||||
- `RedactKeys []string`: keys whose values should be masked in formatted log output.
|
|
||||||
|
|
||||||
### RotationOptions
|
|
||||||
`type RotationOptions struct`
|
|
||||||
|
|
||||||
Rotation configuration passed through to `RotationWriterFactory` when `New` selects a rotating writer.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Filename string`: log file path. `New` only attempts rotation when this field is non-empty.
|
|
||||||
- `MaxSize int`: value forwarded to the rotation writer factory.
|
|
||||||
- `MaxAge int`: value forwarded to the rotation writer factory.
|
|
||||||
- `MaxBackups int`: value forwarded to the rotation writer factory.
|
|
||||||
- `Compress bool`: value forwarded to the rotation writer factory.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### AllOps
|
|
||||||
`func AllOps(err error) iter.Seq[string]`
|
|
||||||
|
|
||||||
Returns an iterator over non-empty `Op` values found by repeatedly calling `errors.Unwrap` on `err`. Operations are yielded from the outermost `*Err` to the innermost one.
|
|
||||||
|
|
||||||
### As
|
|
||||||
`func As(err error, target any) bool`
|
|
||||||
|
|
||||||
Thin wrapper around `errors.As`.
|
|
||||||
|
|
||||||
### Debug
|
|
||||||
`func Debug(msg string, keyvals ...any)`
|
|
||||||
|
|
||||||
Calls `Default().Debug(msg, keyvals...)`.
|
|
||||||
|
|
||||||
### Default
|
|
||||||
`func Default() *Logger`
|
|
||||||
|
|
||||||
Returns the package-level default logger. The package initializes it with `New(Options{Level: LevelInfo})`.
|
|
||||||
|
|
||||||
### E
|
|
||||||
`func E(op, msg string, err error) error`
|
|
||||||
|
|
||||||
Returns `&Err{Op: op, Msg: msg, Err: err}` as an `error`. It always returns a non-nil error value, even when `err` is nil.
|
|
||||||
|
|
||||||
### EWithRecovery
|
|
||||||
`func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error`
|
|
||||||
|
|
||||||
Returns `&Err{Op: op, Msg: msg, Err: err}` with explicit recovery metadata
|
|
||||||
attached to the new error value.
|
|
||||||
|
|
||||||
### ErrCode
|
|
||||||
`func ErrCode(err error) string`
|
|
||||||
|
|
||||||
If `err` contains an `*Err`, returns its `Code`. Otherwise returns the empty string.
|
|
||||||
|
|
||||||
### Error
|
|
||||||
`func Error(msg string, keyvals ...any)`
|
|
||||||
|
|
||||||
Calls `Default().Error(msg, keyvals...)`.
|
|
||||||
|
|
||||||
### FormatStackTrace
|
|
||||||
`func FormatStackTrace(err error) string`
|
|
||||||
|
|
||||||
Collects `AllOps(err)` and joins the operations with `" -> "`. Returns the empty string when no operations are found.
|
|
||||||
|
|
||||||
### Info
|
|
||||||
`func Info(msg string, keyvals ...any)`
|
|
||||||
|
|
||||||
Calls `Default().Info(msg, keyvals...)`.
|
|
||||||
|
|
||||||
### Is
|
|
||||||
`func Is(err, target error) bool`
|
|
||||||
|
|
||||||
Thin wrapper around `errors.Is`.
|
|
||||||
|
|
||||||
### Join
|
|
||||||
`func Join(errs ...error) error`
|
|
||||||
|
|
||||||
Thin wrapper around `errors.Join`.
|
|
||||||
|
|
||||||
### IsRetryable
|
|
||||||
`func IsRetryable(err error) bool`
|
|
||||||
|
|
||||||
Returns whether the first matching `*Err` in the chain is marked retryable.
|
|
||||||
|
|
||||||
### NewCodeWithRecovery
|
|
||||||
`func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
|
|
||||||
|
|
||||||
Returns `&Err{Msg: msg, Code: code}` with recovery metadata attached.
|
|
||||||
|
|
||||||
### LogError
|
|
||||||
`func LogError(err error, op, msg string) error`
|
|
||||||
|
|
||||||
If `err` is nil, returns nil. Otherwise wraps the error with `Wrap(err, op, msg)`, logs `msg` through the default logger at error level with key/value pairs `"op", op, "err", err`, and returns the wrapped error.
|
|
||||||
|
|
||||||
### LogWarn
|
|
||||||
`func LogWarn(err error, op, msg string) error`
|
|
||||||
|
|
||||||
If `err` is nil, returns nil. Otherwise wraps the error with `Wrap(err, op, msg)`, logs `msg` through the default logger at warn level with key/value pairs `"op", op, "err", err`, and returns the wrapped error.
|
|
||||||
|
|
||||||
### Message
|
|
||||||
`func Message(err error) string`
|
|
||||||
|
|
||||||
Returns the `Msg` field from the first matching `*Err`. If `err` is nil, returns the empty string. For non-`*Err` errors, returns `err.Error()`.
|
|
||||||
|
|
||||||
### Must
|
|
||||||
`func Must(err error, op, msg string)`
|
|
||||||
|
|
||||||
If `err` is nil, does nothing. Otherwise logs `msg` through the default logger at error level with key/value pairs `"op", op, "err", err`, then panics with `Wrap(err, op, msg)`.
|
|
||||||
|
|
||||||
### New
|
|
||||||
`func New(opts Options) *Logger`
|
|
||||||
|
|
||||||
Constructs a logger from `opts`. It prefers a rotating writer only when `opts.Rotation` is non-nil, `opts.Rotation.Filename` is non-empty, and `RotationWriterFactory` is set; otherwise it uses `opts.Output`. If neither path yields a writer, it falls back to `os.Stderr`.
|
|
||||||
|
|
||||||
### NewCode
|
|
||||||
`func NewCode(code, msg string) error`
|
|
||||||
|
|
||||||
Returns `&Err{Msg: msg, Code: code}` as an `error`.
|
|
||||||
|
|
||||||
### RecoveryAction
|
|
||||||
`func RecoveryAction(err error) string`
|
|
||||||
|
|
||||||
Returns the first next-action hint from the error chain.
|
|
||||||
|
|
||||||
### RetryAfter
|
|
||||||
`func RetryAfter(err error) (*time.Duration, bool)`
|
|
||||||
|
|
||||||
Returns the first retry-after hint from the error chain, if present.
|
|
||||||
|
|
||||||
### NewError
|
|
||||||
`func NewError(text string) error`
|
|
||||||
|
|
||||||
Thin wrapper around `errors.New`.
|
|
||||||
|
|
||||||
### Op
|
|
||||||
`func Op(err error) string`
|
|
||||||
|
|
||||||
If `err` contains an `*Err`, returns its `Op`. Otherwise returns the empty string.
|
|
||||||
|
|
||||||
### Root
|
|
||||||
`func Root(err error) error`
|
|
||||||
|
|
||||||
Repeatedly unwraps `err` with `errors.Unwrap` until no further wrapped error exists, then returns the last error in that chain. If `err` is nil, returns nil.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
`func Security(msg string, keyvals ...any)`
|
|
||||||
|
|
||||||
Calls `Default().Security(msg, keyvals...)`.
|
|
||||||
|
|
||||||
### SetDefault
|
|
||||||
`func SetDefault(l *Logger)`
|
|
||||||
|
|
||||||
Replaces the package-level default logger with `l`.
|
|
||||||
|
|
||||||
### SetLevel
|
|
||||||
`func SetLevel(level Level)`
|
|
||||||
|
|
||||||
Calls `Default().SetLevel(level)`.
|
|
||||||
|
|
||||||
### SetRedactKeys
|
|
||||||
`func SetRedactKeys(keys ...string)`
|
|
||||||
|
|
||||||
Calls `Default().SetRedactKeys(keys...)`.
|
|
||||||
|
|
||||||
### StackTrace
|
|
||||||
`func StackTrace(err error) []string`
|
|
||||||
|
|
||||||
Collects `AllOps(err)` into a slice in outermost-to-innermost order. When no operations are found, the returned slice is nil.
|
|
||||||
|
|
||||||
### Username
|
|
||||||
`func Username() string`
|
|
||||||
|
|
||||||
Returns the current username by trying `user.Current()` first, then the `USER` environment variable, then the `USERNAME` environment variable.
|
|
||||||
|
|
||||||
### Warn
|
|
||||||
`func Warn(msg string, keyvals ...any)`
|
|
||||||
|
|
||||||
Calls `Default().Warn(msg, keyvals...)`.
|
|
||||||
|
|
||||||
### Wrap
|
|
||||||
`func Wrap(err error, op, msg string) error`
|
|
||||||
|
|
||||||
If `err` is nil, returns nil. Otherwise returns a new `*Err` containing `op`, `msg`, and `err`. If the wrapped error chain already contains an `*Err` with a non-empty `Code`, the new wrapper copies that code.
|
|
||||||
|
|
||||||
### WrapCodeWithRecovery
|
|
||||||
`func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
|
|
||||||
|
|
||||||
Returns nil only when both `err` is nil and `code` is empty. Otherwise it
|
|
||||||
returns a wrapped `*Err` with explicit recovery metadata attached.
|
|
||||||
|
|
||||||
### WrapCode
|
|
||||||
`func WrapCode(err error, code, op, msg string) error`
|
|
||||||
|
|
||||||
Returns nil only when both `err` is nil and `code` is empty. In every other case it returns `&Err{Op: op, Msg: msg, Err: err, Code: code}` as an `error`.
|
|
||||||
|
|
||||||
### WrapWithRecovery
|
|
||||||
`func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error`
|
|
||||||
|
|
||||||
Returns nil when `err` is nil. Otherwise it returns a wrapped `*Err` with explicit recovery metadata attached.
|
|
||||||
Loading…
Add table
Reference in a new issue