REPOSITORY / ScuroNeko/SNekLog

Compare commits

DIFF REPOSITORY
9 Commits
Author SHA1 Message Date
ScuroNeko dd3c7286d2 (new): JSON HTML escaping control
Golang lint / lint (push) Successful in 45s
- add Formatter.JSONEscapeHTML and SetJSONEscapeHTML to let JSON writers preserve HTML-sensitive characters when needed while keeping escaping enabled by default.
- switch JSON output to json.Encoder, preserve existing Print/Println newline semantics, and cover the new escaping behavior in tests.
2026-04-28 14:36:03 +03:00
ScuroNeko a54a854a77 (fix): RELEASE_NOTES.md
Golang lint / lint (push) Successful in 1m44s
2026-04-28 09:46:18 +03:00
ScuroNeko e6d15b530f (new): add threshold-based log level filtering and docs
Golang lint / lint (push) Successful in 1m36s
- add threshold-aware LogLevel constructors
- add Logger.SetThresholdMode and SameThreshold
- preserve legacy SameLevel behavior for compatibility
- extend tests for threshold mode and compatibility
- update README and release notes for v2.2.0
2026-04-28 09:45:40 +03:00
ScuroNeko b7fa3f7606 (chore): document and test new LogLevel APIs
Golang lint / lint (push) Successful in 47s
- add godoc for SameLevel and Equal
- update package docs and READMEs with new helpers
- refresh unreleased release notes
- add tests for Printf, HTTP method levels, and level constructors
2026-04-27 17:04:41 +03:00
ScuroNeko 812caed471 (new): add log level helpers and HTTP method levels
Golang lint / lint (push) Successful in 42s
- move LogLevel and predefined levels into levels.go
- add level-specific constructors for info/warn/error/fatal/debug
- add predefined HTTP method log levels and LogLevelForMethod
- unify Logger Print/Println internals and add Printf
- improve GoDoc coverage for exported APIs
2026-04-27 16:36:30 +03:00
ScuroNeko 3eac1851ec (new): prepare v2.1.0 release
Golang lint / lint (push) Successful in 24s
- add custom LogLevel constructors and richer color configuration
- support ANSI, 256-color, RGB, and text attributes on LogLevel
- introduce clearer preferred APIs such as NewLogger and SetName
- preserve backward compatibility with deprecated wrappers for v2.0.1 APIs
- restore legacy ColorStringBuilder signatures as compatibility wrappers
- fix newline separator handling in Println-style output
- add tests for color precedence, deprecated aliases, and LogLevel attributes
- update GoDoc and bilingual README documentation
2026-04-27 15:42:24 +03:00
ScuroNeko b4c9203a79 (fix): lint error
Golang lint / lint (push) Successful in 50s
2026-04-27 10:24:53 +03:00
ScuroNeko 19f8750d35 (fix): module declaration slog->sneklog
Golang lint / lint (push) Failing after 48s
2026-04-27 09:56:46 +03:00
ScuroNeko d534f92d48 (refactor): prepare breaking v2 API
- rename package to sneklog and move module to /v2
- replace text output flags with writer formatters
- remove external color dependencies
- update migration notes and coverage
2026-04-24 15:43:14 +03:00
20 changed files with 2123 additions and 272 deletions
+12
View File
@@ -0,0 +1,12 @@
name: Golang lint
run-name: Linting code
on: [push]
jobs:
lint:
runs-on: go-latest
steps:
- name: Checkout repository code
uses: actions/checkout@v6
- name: Run golangci-lint
run: golangci-lint run
+2
View File
@@ -1,3 +1,5 @@
.idea/ .idea/
.vscode/
.codex
test/ test/
*.log *.log
+82 -18
View File
@@ -1,4 +1,4 @@
# slog # SNekLog (ScuroNeko Logger)
Small structured logger for Go with text and JSON output, multiple writers, and optional traceback metadata. Small structured logger for Go with text and JSON output, multiple writers, and optional traceback metadata.
@@ -17,7 +17,7 @@ Russian version: [README_ru.md](README_ru.md)
## Installation ## Installation
```bash ```bash
go get git.scuroneko.dev/scuroneko/slog go get git.scuroneko.dev/scuroneko/sneklog/v2
``` ```
## Quick start ## Quick start
@@ -28,15 +28,13 @@ package main
import ( import (
"log" "log"
"git.scuroneko.dev/scuroneko/slog" "git.scuroneko.dev/scuroneko/sneklog/v2"
) )
func main() { func main() {
logger := slog.CreateLogger(). logger := sneklog.NewLogger().
Prefix("API"). SetName("API").
Level(slog.DEBUG). SetLevel(sneklog.DEBUG).
PrintTraceback(true).
JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>") AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter() text := logger.CreateTextStdoutWriter()
@@ -61,15 +59,21 @@ func main() {
## Defaults ## Defaults
`CreateLogger()` starts with: `NewLogger()` starts with:
- `Prefix("LOG")` - `Prefix("LOG")`
- `Level(slog.FATAL)` - `Level(sneklog.FATAL)`
- `PrintTime(true)`
- `PrintTraceback(false)`
- `JsonPretty(false)` - `JsonPretty(false)`
- text formatter: `sneklog.DefaultTextFormatter`
- JSON formatter: `sneklog.DefaultJsonFormatter`
Important: with the current level ordering, `Level(slog.FATAL)` allows `INFO`, `WARN`, `ERROR`, and `FATAL`, but not `DEBUG`. Use `Level(slog.DEBUG)` to enable every level. `CreateLogger()` is still available for backward compatibility.
Important: with the current level ordering, `Level(sneklog.FATAL)` allows `INFO`, `WARN`, `ERROR`, and `FATAL`, but not `DEBUG`. Use `Level(sneklog.DEBUG)` to enable every level.
If you want classic threshold filtering instead, call `SetThresholdMode(true)` and
configure levels with `NewThresholdLogLevel(...)` or
`NewThresholdLogLevelWithColors(...)`.
## Writers and ownership ## Writers and ownership
@@ -92,10 +96,10 @@ This makes it safe to plug in `bytes.Buffer`, network writers, and other externa
Text writers render records like: Text writers render records like:
```text ```text
[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started 2026-03-17T14:05:09+03:00 info API: service started
``` ```
The traceback field is included only when `PrintTraceback(true)` is enabled. Use `SetFormatter` on a writer to customize timestamps, traceback fields, colors, and message layout.
JSON writers emit objects with this shape: JSON writers emit objects with this shape:
@@ -119,6 +123,65 @@ JSON writers emit objects with this shape:
When `JsonPretty(true)` is enabled, JSON is indented. When `JsonPretty(true)` is enabled, JSON is indented.
## Custom levels and colors
You can define your own levels and assign ANSI, 256-color, or RGB colors, plus text attributes such as `Bold` or `Italic`.
```go
httpDelete := sneklog.NewLogLevel(0, "delete")
httpDelete.SetBackgroundRGB(128, 0, 0)
httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold)
httpCache := sneklog.NewLogLevel(0, "cache")
httpCache.SetForeground256Color(214)
```
Because `LogLevel` setters mutate the level in place, call them on a variable, not on a temporary value returned by `NewLogLevel(...)`.
Short forms such as `SetFgColor` and `SetBgColor` remain available for backward compatibility.
Common severities also have dedicated helpers:
```go
access := sneklog.NewInfoLogLevelWithColors("access", sneklog.FgCyan, sneklog.BgNone)
audit := sneklog.NewWarnLogLevel("audit")
```
These helpers keep the legacy severity index for backward compatibility and also
assign default threshold values:
- `INFO`: `th=10`
- `WARN`: `th=20`
- `ERROR`: `th=30`
- `FATAL`: `th=40`
- `DEBUG`: `th=0`
To define custom threshold-based levels explicitly:
```go
trace := sneklog.NewThresholdLogLevelWithColors(4, 5, "trace", sneklog.FgHiBlack, sneklog.BgNone)
audit := sneklog.NewThresholdLogLevel(1, 25, "audit")
logger := sneklog.NewLogger().
SetLevel(audit).
SetThresholdMode(true)
logger.Print(trace, "verbose trace") // filtered out
logger.Print(audit, "audit event") // allowed
```
HTTP method-specific predefined levels are available out of the box:
```go
level := sneklog.LogLevelForMethod(http.MethodPost)
logger.Print(level, "POST /users")
```
When comparing levels:
- use `SameLevel` if only the legacy severity index matters;
- use `SameThreshold` if only threshold filtering matters;
- use `Equal` if the full configuration, including threshold, colors, and attributes, must match.
## Message replacement ## Message replacement
`AddReplacer(old, new)` replaces matching text in every message before the `AddReplacer(old, new)` replaces matching text in every message before the
@@ -126,8 +189,8 @@ record reaches any writer. Replacement rules are applied in the order they are
added. added.
```go ```go
logger := slog.CreateLogger(). logger := sneklog.CreateLogger().
Level(slog.DEBUG). Level(sneklog.DEBUG).
AddReplacer("SOME_SECRET", "<redacted>"). AddReplacer("SOME_SECRET", "<redacted>").
AddReplacer("user@example.com", "<email>") AddReplacer("user@example.com", "<email>")
@@ -141,6 +204,7 @@ An empty `old` value is ignored.
- `Info`, `Warn`, `Error`, `Debug`, and `Fatal` accept a list of values. - `Info`, `Warn`, `Error`, `Debug`, and `Fatal` accept a list of values.
- `Infof`, `Warnf`, `Errorf`, `Debugf`, and `Fatalf` use `fmt.Sprintf`. - `Infof`, `Warnf`, `Errorf`, `Debugf`, and `Fatalf` use `fmt.Sprintf`.
- `Printf(level, format, args...)` formats a message for an explicit `LogLevel`.
- The `*ln` methods preserve newline semantics, which is useful for `stdout`, Docker, and line-based collectors. - The `*ln` methods preserve newline semantics, which is useful for `stdout`, Docker, and line-based collectors.
- `Fatal`, `Fatalf`, and `Fatalln` call `os.Exit(1)` after writing the message. - `Fatal`, `Fatalf`, and `Fatalln` call `os.Exit(1)` after writing the message.
- `AddReplacer` masks or rewrites message text before records are sent to writers. - `AddReplacer` masks or rewrites message text before records are sent to writers.
@@ -149,7 +213,7 @@ An empty `old` value is ignored.
- Text writers use the nearest user stack frame. - Text writers use the nearest user stack frame.
- JSON writers receive the full traceback slice. - JSON writers receive the full traceback slice.
- Internal `slog` frames and `runtime` frames are filtered out. - Internal `sneklog` frames and `runtime` frames are filtered out.
## Repository example ## Repository example
+82 -18
View File
@@ -1,4 +1,4 @@
# slog # SNekLog (ScuroNeko Logger)
Небольшой структурированный логгер для Go с текстовым и JSON-выводом, несколькими writer'ами и настраиваемыми traceback-метаданными. Небольшой структурированный логгер для Go с текстовым и JSON-выводом, несколькими writer'ами и настраиваемыми traceback-метаданными.
@@ -17,7 +17,7 @@ English version: [README.md](README.md)
## Установка ## Установка
```bash ```bash
go get git.scuroneko.dev/scuroneko/slog go get git.scuroneko.dev/scuroneko/sneklog/v2
``` ```
## Быстрый старт ## Быстрый старт
@@ -28,15 +28,13 @@ package main
import ( import (
"log" "log"
"git.scuroneko.dev/scuroneko/slog" "git.scuroneko.dev/scuroneko/sneklog/v2"
) )
func main() { func main() {
logger := slog.CreateLogger(). logger := sneklog.NewLogger().
Prefix("API"). SetName("API").
Level(slog.DEBUG). SetLevel(sneklog.DEBUG).
PrintTraceback(true).
JsonPretty(true).
AddReplacer("SOME_SECRET", "<redacted>") AddReplacer("SOME_SECRET", "<redacted>")
text := logger.CreateTextStdoutWriter() text := logger.CreateTextStdoutWriter()
@@ -61,15 +59,21 @@ func main() {
## Значения по умолчанию ## Значения по умолчанию
`CreateLogger()` создает логгер со следующими настройками: `NewLogger()` создает логгер со следующими настройками:
- `Prefix("LOG")` - `Prefix("LOG")`
- `Level(slog.FATAL)` - `Level(sneklog.FATAL)`
- `PrintTime(true)`
- `PrintTraceback(false)`
- `JsonPretty(false)` - `JsonPretty(false)`
- текстовый formatter: `sneklog.DefaultTextFormatter`
- JSON formatter: `sneklog.DefaultJsonFormatter`
Важно: в текущей модели уровней `Level(slog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(slog.DEBUG)`. `CreateLogger()` по-прежнему доступен для обратной совместимости.
Важно: в текущей модели уровней `Level(sneklog.FATAL)` пропускает `INFO`, `WARN`, `ERROR` и `FATAL`, но не `DEBUG`. Чтобы включить все сообщения, используйте `Level(sneklog.DEBUG)`.
Если нужна классическая threshold-фильтрация, включите `SetThresholdMode(true)` и
задавайте уровни через `NewThresholdLogLevel(...)` или
`NewThresholdLogLevelWithColors(...)`.
## Writer'ы и владение ## Writer'ы и владение
@@ -92,10 +96,10 @@ func main() {
Текстовый writer формирует записи вида: Текстовый writer формирует записи вида:
```text ```text
[API] [INFO] [main.go:main:27] [17.03.26 14:05:09] service started 2026-03-17T14:05:09+03:00 info API: service started
``` ```
Поле traceback появляется только если включен `PrintTraceback(true)`. Используйте `SetFormatter` у writer'а, чтобы настроить timestamp'ы, traceback-поля, цвета и шаблон сообщения.
JSON writer записывает объект со следующими полями: JSON writer записывает объект со следующими полями:
@@ -119,14 +123,73 @@ JSON writer записывает объект со следующими поля
Если включен `JsonPretty(true)`, JSON выводится с отступами. Если включен `JsonPretty(true)`, JSON выводится с отступами.
## Кастомные уровни и цвета
Можно создавать собственные уровни и назначать им ANSI, 256-color или RGB-цвета, а также текстовые атрибуты вроде `Bold` и `Italic`.
```go
httpDelete := sneklog.NewLogLevel(0, "delete")
httpDelete.SetBackgroundRGB(128, 0, 0)
httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold)
httpCache := sneklog.NewLogLevel(0, "cache")
httpCache.SetForeground256Color(214)
```
Так как setter'ы `LogLevel` изменяют уровень на месте, их нужно вызывать на переменной, а не на временном результате `NewLogLevel(...)`.
Короткие формы вроде `SetFgColor` и `SetBgColor` сохранены для обратной совместимости.
Для стандартных severity также есть отдельные helper'ы:
```go
access := sneklog.NewInfoLogLevelWithColors("access", sneklog.FgCyan, sneklog.BgNone)
audit := sneklog.NewWarnLogLevel("audit")
```
Эти helper'ы сохраняют legacy severity index для обратной совместимости и
одновременно выставляют значения threshold по умолчанию:
- `INFO`: `th=10`
- `WARN`: `th=20`
- `ERROR`: `th=30`
- `FATAL`: `th=40`
- `DEBUG`: `th=0`
Чтобы явно создавать уровни для threshold-режима:
```go
trace := sneklog.NewThresholdLogLevelWithColors(4, 5, "trace", sneklog.FgHiBlack, sneklog.BgNone)
audit := sneklog.NewThresholdLogLevel(1, 25, "audit")
logger := sneklog.NewLogger().
SetLevel(audit).
SetThresholdMode(true)
logger.Print(trace, "verbose trace") // будет отфильтровано
logger.Print(audit, "audit event") // будет записано
```
Для HTTP-методов доступны готовые уровни:
```go
level := sneklog.LogLevelForMethod(http.MethodPost)
logger.Print(level, "POST /users")
```
При сравнении уровней:
- используйте `SameLevel`, если важен только legacy severity index;
- используйте `SameThreshold`, если важна только threshold-фильтрация;
- используйте `Equal`, если нужно полное совпадение конфигурации, включая threshold, цвета и атрибуты.
## Замена сообщений ## Замена сообщений
`AddReplacer(old, new)` заменяет найденный текст в каждом сообщении до того, `AddReplacer(old, new)` заменяет найденный текст в каждом сообщении до того,
как запись попадет в writer'ы. Правила замены применяются в порядке добавления. как запись попадет в writer'ы. Правила замены применяются в порядке добавления.
```go ```go
logger := slog.CreateLogger(). logger := sneklog.CreateLogger().
Level(slog.DEBUG). Level(sneklog.DEBUG).
AddReplacer("SOME_SECRET", "<redacted>"). AddReplacer("SOME_SECRET", "<redacted>").
AddReplacer("user@example.com", "<email>") AddReplacer("user@example.com", "<email>")
@@ -140,6 +203,7 @@ logger.Infoln("login token:", "SOME_SECRET")
- `Info`, `Warn`, `Error`, `Debug`, `Fatal` принимают список значений. - `Info`, `Warn`, `Error`, `Debug`, `Fatal` принимают список значений.
- `Infof`, `Warnf`, `Errorf`, `Debugf`, `Fatalf` используют `fmt.Sprintf`. - `Infof`, `Warnf`, `Errorf`, `Debugf`, `Fatalf` используют `fmt.Sprintf`.
- `Printf(level, format, args...)` форматирует сообщение для явного `LogLevel`.
- Методы `*ln` добавляют семантику перевода строки, что удобно для `stdout`, Docker и line-based collectors. - Методы `*ln` добавляют семантику перевода строки, что удобно для `stdout`, Docker и line-based collectors.
- `Fatal`, `Fatalf` и `Fatalln` вызывают `os.Exit(1)` после записи сообщения. - `Fatal`, `Fatalf` и `Fatalln` вызывают `os.Exit(1)` после записи сообщения.
- `AddReplacer` маскирует или переписывает текст сообщений перед отправкой в writer'ы. - `AddReplacer` маскирует или переписывает текст сообщений перед отправкой в writer'ы.
@@ -148,7 +212,7 @@ logger.Infoln("login token:", "SOME_SECRET")
- Текстовые writer'ы используют ближайший пользовательский stack frame. - Текстовые writer'ы используют ближайший пользовательский stack frame.
- JSON writer'ы получают полный traceback. - JSON writer'ы получают полный traceback.
- Внутренние frame'ы `slog` и `runtime` фильтруются из traceback. - Внутренние frame'ы `sneklog` и `runtime` фильтруются из traceback.
## Пример из репозитория ## Пример из репозитория
+201
View File
@@ -1,3 +1,204 @@
# v2.3.0
This release adds JSON HTML-escaping control while preserving the existing
JSON writer defaults and newline semantics.
### Added
- Added `Formatter.SetJSONEscapeHTML(bool)` and `Formatter.JSONEscapeHTML` to control whether JSON writers escape HTML-sensitive characters such as `<`, `>`, and `&`.
### Changed
- JSON writers now use `json.Encoder` internally so formatter-level HTML escaping can be configured.
### Fixed
- Preserved `Print` and `Println` newline behavior for JSON output while switching to `json.Encoder`.
### Compatibility
- No breaking API changes.
- HTML escaping remains enabled by default, matching the previous `encoding/json` behavior.
- `Print` still emits JSON without an added newline, and `Println` emits exactly one trailing newline.
# v2.2.0
This release expands the public LogLevel API, adds optional threshold-based
filtering alongside the legacy severity ordering, and improves documentation
and test coverage.
### Added
- Added dedicated log level constructors:
- `NewInfoLogLevel`
- `NewInfoLogLevelWithColors`
- `NewWarnLogLevel`
- `NewWarnLogLevelWithColors`
- `NewErrorLogLevel`
- `NewErrorLogLevelWithColors`
- `NewFatalLogLevel`
- `NewFatalLogLevelWithColors`
- `NewDebugLogLevel`
- `NewDebugLogLevelWithColors`
- Added threshold-aware log level constructors:
- `NewThresholdLogLevel`
- `NewThresholdLogLevelWithColors`
- Added HTTP method-specific predefined log levels:
- `HTTPGetLevel`
- `HTTPHeadLevel`
- `HTTPPostLevel`
- `HTTPPutLevel`
- `HTTPPatchLevel`
- `HTTPDeleteLevel`
- `HTTPOptionsLevel`
- `HTTPConnectLevel`
- `HTTPTraceLevel`
- `HTTPUnknownLevel`
- Added `LogLevelForMethod(method string) LogLevel` to map HTTP methods to predefined log levels.
- Added `Logger.Printf(level, format, args...)` for formatted logging with an explicit log level.
- Added `Logger.SetThresholdMode(bool)` to switch filtering from legacy severity-index ordering to threshold-based ordering.
- Added `LogLevel.SameLevel`, `LogLevel.SameThreshold`, and `LogLevel.Equal` for severity-only, threshold-only, and full level comparisons.
### Changed
- Refactored log level definitions into a dedicated `levels.go` file.
- Unified `Logger.Print` and `Logger.Println` through a shared internal implementation without changing their behavior.
- Common helper constructors such as `NewInfoLogLevel` now also assign threshold values while preserving legacy severity indexes.
- `LogLevel.Equal` now includes threshold configuration in full equality checks.
- Expanded test coverage for `Logger.Printf`, HTTP level mapping, constructor behavior, threshold filtering, and backward compatibility.
### Documentation
- Improved GoDoc coverage for exported types, constructors, formatters, colors, HTTP helpers, and time layouts.
- Documented threshold mode, threshold-aware constructors, level comparison helpers, and explicit-level formatted logging.
- Expanded inline documentation for formatter tokens and default formatter behavior.
- Updated English and Russian README files with threshold-mode examples and comparison guidance.
### Compatibility
- No breaking API changes.
- Existing `Print`, `Println`, predefined levels, `Level/SetLevel`, and color APIs remain compatible.
- Legacy severity ordering remains the default; threshold filtering is enabled only when `SetThresholdMode(true)` is called.
- `SameLevel` remains severity-only for backward compatibility, even when levels carry threshold metadata.
# v2.1.0
This release expands sneklog color customization while preserving compatibility with the v2.0.1 API.
## Added
- custom LogLevel constructors with configurable colors
- 256-color and RGB support for LogLevel
- ANSI text attributes on LogLevel such as Bold and Italic
- typed color helpers for foreground/background RGB and 256-color values
- NewLogger, SetName, SetLevel, and SetJSONPretty as clearer preferred APIs
## Improved
- text and JSON writer handling of trailing newline semantics
- formatter colorization logic for foreground, background, and attributes
- GoDoc coverage across the public API
- English and Russian README examples and API guidance
## Compatibility
- legacy APIs from v2.0.1 remain available as deprecated wrappers
- legacy ColorStringBuilder method signatures were preserved for backward compatibility
- deprecated methods now explicitly document their replacements and planned removal in v3
## Fixed
- extra separator/space issues in Println-style output
- attribute slice handling and defensive copying for LogLevel
- color mode precedence when switching between ANSI, 256-color, and RGB modes
# v2.0.0
## Highlights
- Renamed the public Go package from `slog` to `sneklog` to avoid confusion with the standard library `log/slog` package.
- Moved the module path to `git.scuroneko.dev/scuroneko/sneklog/v2` for Go module major-version compatibility.
- Replaced fixed text writer settings with formatter-based output customization.
- Added built-in ANSI color support and removed the external `github.com/fatih/color` dependency tree.
- Formatters: a new way do display your logs!
## Breaking Changes
- Import paths must use the `/v2` module suffix:
```go
import "git.scuroneko.dev/scuroneko/sneklog/v2"
```
- Package references should use `sneklog` instead of `slog`.
- Removed `Logger.PrintTime` and `Logger.PrintTraceback`; configure output through writer formatters instead.
- Changed text writer constructors:
- `CreateTextWriter(w, printTraceback, printTime)` is now `CreateTextWriter(w)`.
- `CreateTextFileWriter(path, printTraceback, printTime)` is now `CreateTextFileWriter(path)`.
- `CreateTextStdoutWriter(printTraceback, printTime)` is now `CreateTextStdoutWriter()`.
- Removed the old text formatting helpers: `BuildString`, `FormatTime`, `FormatTraceback`, and `FormatFullTraceback`.
- JSON `time` values are now formatted through the configured formatter and stored as strings in `LoggerJsonMessage`.
## Added
- `Formatter` for configurable message layouts, timestamp layouts, traceback layouts, separators, and color behavior.
- `DefaultTextFormatter` and `DefaultJsonFormatter` defaults for text and JSON writers.
- `LoggerTextWriter.SetFormatter` and `LoggerJsonWriter.SetFormatter`.
- Strftime-like timestamp format constants such as `RFC3339`, `Kitchen`, `DateTime`, `DateOnly`, and `TimeOnly`.
- Built-in color types and helpers:
- `FgColor`, `BgColor`, and `Attribute`.
- `ColorStringBuilder`.
- Level color getters/setters on `LogLevel`.
- Tests covering formatter defaults, empty traceback placeholders, literal `%` sequences in messages, color controls, and JSON newline semantics.
## Changed
- Text output defaults to `DefaultTextFormatter` instead of the old hard-coded string builder.
- JSON output defaults to `DefaultJsonFormatter` and can now format the JSON `message` field through `Formatter`.
- Newline semantics are normalized before text and JSON formatting, so trailing `\n` controls record termination without becoming part of the message.
- Text color output is enabled by default only for stdout/stderr.
- Example code now demonstrates custom formatters and color configuration.
- Go dependencies were simplified by removing:
- `github.com/fatih/color`
- `github.com/mattn/go-colorable`
- `github.com/mattn/go-isatty`
- `golang.org/x/sys`
## Fixed
- `NewFormatter()` now returns a fresh copy instead of mutating `DefaultTextFormatter`.
- `Formatter.FormatMessage` no longer panics when called with an empty traceback.
- Placeholder-looking text inside log messages, such as `%L` or `%s`, is preserved literally.
- `SetColorOutput(false)` disables color output.
- `SetColorOnlyStdout(false)` allows color output for non-stdout writers.
## Migration
Before:
```go
import "git.scuroneko.dev/scuroneko/slog"
logger := slog.CreateLogger().
Prefix("API").
Level(slog.DEBUG).
PrintTraceback(true)
text := logger.CreateTextStdoutWriter()
```
After:
```go
import sneklog "git.scuroneko.dev/scuroneko/sneklog/v2"
logger := sneklog.CreateLogger().
Prefix("API").
Level(sneklog.DEBUG)
formatter := sneklog.NewFormatter().
SetFormat("%t [%L] %N: %m (%B)")
text := logger.CreateTextStdoutWriter().
SetFormatter(formatter)
```
# v1.2.0 # v1.2.0
## Highlights ## Highlights
+301
View File
@@ -0,0 +1,301 @@
package sneklog
import (
"fmt"
"strings"
)
// FgColor is a basic ANSI foreground color code.
type FgColor uint8
// BgColor is a basic ANSI background color code.
type BgColor uint8
// FgColor256 is an ANSI 256-color foreground value.
type FgColor256 uint8
// BgColor256 is an ANSI 256-color background value.
type BgColor256 uint8
// BgColorRGB is an ANSI truecolor background value encoded as RGB bytes.
type BgColorRGB []uint8
// FgColorRGB is an ANSI truecolor foreground value encoded as RGB bytes.
type FgColorRGB []uint8
// Attribute is an ANSI text attribute code.
type Attribute uint8
// NewBgColorRGB builds an RGB background color value.
func NewBgColorRGB(r, g, b uint8) BgColorRGB {
return BgColorRGB{r, g, b}
}
// NewFgColorRGB builds an RGB foreground color value.
func NewFgColorRGB(r, g, b uint8) FgColorRGB {
return FgColorRGB{r, g, b}
}
// Text attribute codes.
const (
Reset Attribute = iota
Bold
Faint
Italic
Underline
BlinkSlow
BlinkRapid
Invert
Conceal
CrossedOut
)
// Additional text attribute codes.
const (
DoubleUnderline Attribute = iota + 21 // Can be disable bold on some terminals
DisableBold
DisableItalic
DisableUnderline
DisableBlink
DisableInvert
DisableConceal
DisableCrossedOut
)
// Basic foreground color codes.
const (
FgNone = 0
FgBlack FgColor = iota + 30
FgRed
FgGreen
FgYellow
FgBlue
FgMagenta
FgCyan
FgWhite
FgDefault FgColor = 39
)
// Basic background color codes.
const (
BgNone = 0
BgBlack BgColor = iota + 40
BgRed
BgGreen
BgYellow
BgBlue
BgMagenta
BgCyan
BgWhite
BgDefault BgColor = 49
)
// High-intensity foreground color codes.
const (
FgHiBlack FgColor = iota + 90
FgHiRed
FgHiGreen
FgHiYellow
FgHiBlue
FgHiMagenta
FgHiCyan
FgHiWhite
)
// High-intensity background color codes.
const (
BgHiBlack BgColor = iota + 100
BgHiRed
BgHiGreen
BgHiYellow
BgHiBlue
BgHiMagenta
BgHiCyan
BgHiWhite
)
// Rarely used text attribute codes that are not supported by all terminals.
const (
Border Attribute = iota + 51
Outline
Upperline
DisableBorderOutline
DisableUpperline
)
// ColorStringBuilder incrementally builds ANSI-colored strings.
type ColorStringBuilder struct {
str string
}
// NewColorStringBuilder creates a builder for composing ANSI-colored strings.
func NewColorStringBuilder() *ColorStringBuilder {
return &ColorStringBuilder{str: ""}
}
// AddAttribute appends an ANSI text attribute sequence.
func (builder *ColorStringBuilder) AddAttribute(attr Attribute) *ColorStringBuilder {
builder.str += attr.String()
return builder
}
// AddFgColor appends a basic ANSI foreground color sequence.
func (builder *ColorStringBuilder) AddFgColor(color FgColor) *ColorStringBuilder {
if color <= 0 {
return builder
}
builder.str += color.String()
return builder
}
// AddBgColor appends a basic ANSI background color sequence.
func (builder *ColorStringBuilder) AddBgColor(color BgColor) *ColorStringBuilder {
if color <= 0 {
return builder
}
builder.str += color.String()
return builder
}
// AddForeground256Color appends a 256-color foreground sequence.
func (builder *ColorStringBuilder) AddForeground256Color(color FgColor256) *ColorStringBuilder {
builder.str += color.String()
return builder
}
// AddColor256Fg appends a 256-color foreground sequence.
// Deprecated: use AddForeground256Color. This method will be removed in v3.
func (builder *ColorStringBuilder) AddColor256Fg(color uint8) *ColorStringBuilder {
return builder.AddForeground256Color(FgColor256(color))
}
// AddBackground256Color appends a 256-color background sequence.
func (builder *ColorStringBuilder) AddBackground256Color(color BgColor256) *ColorStringBuilder {
builder.str += color.String()
return builder
}
// AddColor256Bg appends a 256-color background sequence.
// Deprecated: use AddBackground256Color. This method will be removed in v3.
func (builder *ColorStringBuilder) AddColor256Bg(color uint8) *ColorStringBuilder {
return builder.AddBackground256Color(BgColor256(color))
}
// AddForegroundRGB appends an RGB foreground sequence.
func (builder *ColorStringBuilder) AddForegroundRGB(color FgColorRGB) *ColorStringBuilder {
builder.str += color.String()
return builder
}
// AddColorRgbFg appends an RGB foreground sequence.
// Deprecated: use AddForegroundRGB. This method will be removed in v3.
func (builder *ColorStringBuilder) AddColorRgbFg(r, g, b uint8) *ColorStringBuilder {
return builder.AddForegroundRGB(FgColorRGB{r, g, b})
}
// AddBackgroundRGB appends an RGB background sequence.
func (builder *ColorStringBuilder) AddBackgroundRGB(color BgColorRGB) *ColorStringBuilder {
builder.str += color.String()
return builder
}
// AddColorRgbBg appends an RGB background sequence.
// Deprecated: use AddBackgroundRGB. This method will be removed in v3.
func (builder *ColorStringBuilder) AddColorRgbBg(r, g, b uint8) *ColorStringBuilder {
return builder.AddBackgroundRGB(BgColorRGB{r, g, b})
}
// AddReset appends the ANSI reset sequence.
func (builder *ColorStringBuilder) AddReset() *ColorStringBuilder {
builder.str += "\x1b[0m"
return builder
}
// AddText appends plain text to the builder.
func (builder *ColorStringBuilder) AddText(text string) *ColorStringBuilder {
builder.str += text
return builder
}
// String returns the built string and appends a reset sequence when needed.
func (builder *ColorStringBuilder) String() string {
if strings.Contains(builder.str, "\x1b[") && !strings.HasSuffix(builder.str, "\x1b[0m") {
builder.AddReset()
}
return builder.str
}
// String returns the ANSI escape sequence for the attribute.
func (a Attribute) String() string {
return fmt.Sprintf("\x1b[%dm", a)
}
// Uint8 returns the raw attribute value.
func (a Attribute) Uint8() uint8 {
return uint8(a)
}
// Int returns the raw attribute value as int.
func (a Attribute) Int() int {
return int(a)
}
// String returns the ANSI escape sequence for the foreground color.
func (c FgColor) String() string {
return fmt.Sprintf("\x1b[%dm", c)
}
// Uint8 returns the raw foreground color value.
func (c FgColor) Uint8() uint8 {
return uint8(c)
}
// Int returns the raw foreground color value as int.
func (c FgColor) Int() int {
return int(c)
}
// String returns the ANSI escape sequence for the background color.
func (c BgColor) String() string {
return fmt.Sprintf("\x1b[%dm", c)
}
// Uint8 returns the raw background color value.
func (c BgColor) Uint8() uint8 {
return uint8(c)
}
// Int returns the raw background color value as int.
func (c BgColor) Int() int {
return int(c)
}
// String returns the ANSI escape sequence for the 256-color foreground value.
func (c FgColor256) String() string { return fmt.Sprintf("\u001B[38;5;%dm", c) }
// Uint8 returns the raw 256-color foreground value.
func (c FgColor256) Uint8() uint8 { return uint8(c) }
// Int returns the raw 256-color foreground value as int.
func (c FgColor256) Int() int { return int(c) }
// String returns the ANSI escape sequence for the 256-color background value.
func (c BgColor256) String() string { return fmt.Sprintf("\u001B[48;5;%dm", c) }
// Uint8 returns the raw 256-color background value.
func (c BgColor256) Uint8() uint8 { return uint8(c) }
// Int returns the raw 256-color background value as int.
func (c BgColor256) Int() int { return int(c) }
// String returns the ANSI escape sequence for the RGB foreground value.
func (c FgColorRGB) String() string { return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", c[0], c[1], c[2]) }
// Uint8 returns the raw RGB foreground components.
func (c FgColorRGB) Uint8() []uint8 { return c }
// String returns the ANSI escape sequence for the RGB background value.
func (c BgColorRGB) String() string { return fmt.Sprintf("\x1b[48;2;%d;%d;%dm", c[0], c[1], c[2]) }
// Uint8 returns the raw RGB background components.
func (c BgColorRGB) Uint8() []uint8 { return c }
+17 -9
View File
@@ -1,4 +1,4 @@
// Package slog provides a small structured logger that can write the same // Package sneklog provides a small structured logger that can write the same
// record to text and JSON outputs. // record to text and JSON outputs.
// //
// A Logger can fan out records to stdout, files, or any external io.Writer. // A Logger can fan out records to stdout, files, or any external io.Writer.
@@ -8,21 +8,25 @@
// CreateLogger uses the following defaults: // CreateLogger uses the following defaults:
// - prefix: "LOG" // - prefix: "LOG"
// - level: FATAL, which enables info, warn, error, and fatal records // - level: FATAL, which enables info, warn, error, and fatal records
// - text timestamps: enabled
// - text traceback: disabled
// - pretty JSON: disabled // - pretty JSON: disabled
// - text formatter: DefaultTextFormatter
// - JSON formatter: DefaultJsonFormatter
// //
// Call Level(DEBUG) to enable all records, including debug messages. // Call Level(DEBUG) to enable all records, including debug messages.
// AddReplacer can be used to mask or normalize message text before records are // AddReplacer can be used to mask or normalize message text before records are
// passed to writers. // passed to writers. Call SetFormatter on a writer to customize timestamps,
// traceback fields, colors, and message layout.
//
// The package also provides helper constructors for common severities such as
// NewInfoLogLevel and NewErrorLogLevel, plus predefined HTTP method-specific
// levels such as HTTPGetLevel and HTTPPostLevel. Use LogLevelForMethod to map
// an HTTP method to one of those predefined levels.
// //
// Basic usage: // Basic usage:
// //
// logger := slog.CreateLogger(). // logger := sneklog.CreateLogger().
// Prefix("API"). // Prefix("API").
// Level(slog.DEBUG). // Level(sneklog.DEBUG).
// PrintTraceback(true).
// JsonPretty(true).
// AddReplacer("secret-token", "<redacted>") // AddReplacer("secret-token", "<redacted>")
// //
// text := logger.CreateTextStdoutWriter() // text := logger.CreateTextStdoutWriter()
@@ -42,4 +46,8 @@
// the logger and are closed by Logger.Close. Writers created from existing // the logger and are closed by Logger.Close. Writers created from existing
// io.Writer values through CreateTextWriter or CreateJsonWriter remain owned by // io.Writer values through CreateTextWriter or CreateJsonWriter remain owned by
// the caller and are not closed by the logger. // the caller and are not closed by the logger.
package slog //
// LogLevel values can be compared with SameLevel when only the legacy severity
// index matters, with SameThreshold when only threshold filtering matters, or
// with Equal when the full level configuration must match.
package sneklog
+43 -10
View File
@@ -2,30 +2,51 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"git.scuroneko.dev/scuroneko/slog" "git.scuroneko.dev/scuroneko/sneklog/v2"
) )
func main() { func main() {
logger := slog.CreateLogger(). httpGet := sneklog.NewLogLevelWithColors(0, "get", sneklog.FgGreen, sneklog.BgNone)
Prefix("EXAMPLE"). httpPost := sneklog.NewLogLevelWithColors(0, "post", sneklog.FgBlue, sneklog.BgNone)
Level(slog.DEBUG). httpDelete := sneklog.NewLogLevel(0, "delete")
JsonPretty(true). httpDelete.SetBackgroundRGB(128, 0, 0)
httpDelete.AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold)
logger := sneklog.NewLogger().
SetName("EXAMPLE").
SetLevel(sneklog.DEBUG).
SetJSONPretty(true).
AddReplacer("SOME_SECRET", "<Secret>") AddReplacer("SOME_SECRET", "<Secret>")
sneklog.INFO.SetBgColor(sneklog.BgBlue).SetFgColor(sneklog.FgWhite)
textStdout := logger.CreateTextStdoutWriter() textStdout := logger.CreateTextStdoutWriter()
jsonStdout := logger.CreateJsonStdoutWriter() jsonStdout := logger.CreateJsonStdoutWriter()
formatter := sneklog.NewFormatter().
SetFormat("[%t] [%L] [%N]: %m (%s)").
SetTimeStampFormat(sneklog.Kitchen).
SetTraceBackFormat("%s %f:%n %p")
jsonFormatter := sneklog.NewFormatter().
SetFormat("[%L] [%N] %m").
SetColorOutput(false).
SetJSONEscapeHTML(false).
SetTimeStampFormat(sneklog.Kitchen)
textStdout.SetFormatter(formatter)
jsonStdout.SetFormatter(jsonFormatter)
textFile, err := logger.CreateTextFileWriter("logs/text.log") textFile, err := logger.CreateTextFileWriter("logs/text.log")
if err != nil { if err != nil {
logger.Close() _ = logger.Close()
panic(err) panic(err)
} }
jsonFile, err := logger.CreateJsonFileWriter("logs/json.log") jsonFile, err := logger.CreateJsonFileWriter("logs/json.log")
if err != nil { if err != nil {
logger.Close() _ = logger.Close()
panic(err) panic(err)
} }
@@ -42,16 +63,28 @@ func main() {
externalJSON, externalJSON,
) )
logger.Infoln("Тест <>&")
logger.Infoln("service started") logger.Infoln("service started")
logger.Warnln("cache miss") logger.Warnln("cache miss")
logger.Errorln("request failed") logger.Errorln("request failed")
logger.Debugln("debug details") logger.Debugln("debug details")
logger.Infoln("sensitive info, SOME_SECRET") logger.Infoln("sensitive info, SOME_SECRET")
logger.Println(httpGet, "this can be logging of http get request/response...")
logger.Println(httpPost, "...and this can be logging of http post request/response...")
logger.Println(httpDelete, "...but this logging of http delete request/response with highly customizable level!")
if err := logger.Close(); err != nil { if err := logger.Close(); err != nil {
panic(err) panic(err)
} }
fmt.Println("external buffer contents:") s := sneklog.NewColorStringBuilder().
fmt.Println(externalBuffer.String()) AddBackgroundRGB(sneklog.NewBgColorRGB(31, 41, 40)).
AddForegroundRGB(sneklog.NewFgColorRGB(220, 215, 186)).
AddAttribute(sneklog.Italic).AddAttribute(sneklog.Bold).
AddText("Some Very very very cool stuff, themed in Kanagawa colors!").String()
println(s)
//fmt.Println("external buffer contents:")
//fmt.Println(externalBuffer.String())
} }
+236
View File
@@ -0,0 +1,236 @@
package sneklog
import (
"fmt"
"strings"
"time"
)
// Formatter configures how log records are rendered.
//
// Format tokens:
// - %t: time
// - %N: name
// - %l: level
// - %L: uppercase level
// - %b: first traceback frame
// - %B: full traceback
// - %m: message
// - %M: method
// - %f: filename
// - %n: line number
// - %s: method signature
// - %p: full path
//
// Example format: "%t [%l] %N: %m (%f:%n %s)"
//
// TimeStampFormat uses strftime-like directives such as %Y, %m, %d, %H, %M,
// %S, %z, and %Z.
//
// TraceBackFormat supports the traceback-related tokens %M, %f, %n, %s, and %p.
type Formatter struct {
Format string
MessageSeparator string
// JSONTraceBackPath enables full file paths in formatted traceback fields.
JSONTraceBackPath bool
// JSONEscapeHTML controls whether JSON output escapes HTML-sensitive characters.
JSONEscapeHTML bool
TimeStampFormat string
TraceBackFormat string
TraceBackSeparator string
ColorOutput bool
ColorOnlyStdout bool
}
// DefaultTextFormatter is the default formatter used by text writers.
var DefaultTextFormatter = &Formatter{
Format: "%t %l %N: %m",
MessageSeparator: " ",
TimeStampFormat: RFC3339,
TraceBackFormat: "%M (%f:%n %s)",
TraceBackSeparator: "->",
ColorOutput: true,
ColorOnlyStdout: true,
}
// DefaultJsonFormatter is the default formatter used by JSON writers.
var DefaultJsonFormatter = &Formatter{
Format: "%m",
JSONEscapeHTML: true,
MessageSeparator: " ",
TimeStampFormat: RFC3339,
ColorOutput: false,
}
// NewFormatter returns a copy of the default text formatter settings.
func NewFormatter() *Formatter {
return &Formatter{
Format: DefaultTextFormatter.Format,
MessageSeparator: DefaultTextFormatter.MessageSeparator,
JSONEscapeHTML: true,
TimeStampFormat: DefaultTextFormatter.TimeStampFormat,
TraceBackFormat: DefaultTextFormatter.TraceBackFormat,
TraceBackSeparator: DefaultTextFormatter.TraceBackSeparator,
ColorOutput: DefaultTextFormatter.ColorOutput,
ColorOnlyStdout: DefaultTextFormatter.ColorOnlyStdout,
}
}
// SetFormat sets the formatter template used for text rendering.
func (f *Formatter) SetFormat(format string) *Formatter {
f.Format = format
return f
}
// SetMessageSeparator sets the separator used to join message parts.
func (f *Formatter) SetMessageSeparator(separator string) *Formatter {
f.MessageSeparator = separator
return f
}
// SetJSONTraceBackPath enables or disables full file paths in formatted traceback fields.
func (f *Formatter) SetJSONTraceBackPath(set bool) *Formatter {
f.JSONTraceBackPath = set
return f
}
// SetJSONEscapeHTML enables or disables HTML escaping in JSON output.
func (f *Formatter) SetJSONEscapeHTML(escape bool) *Formatter {
f.JSONEscapeHTML = escape
return f
}
// SetTimeStampFormat sets the strftime-like format used for timestamps.
func (f *Formatter) SetTimeStampFormat(format string) *Formatter {
f.TimeStampFormat = format
return f
}
// SetTraceBackFormat sets the format used for a single traceback frame.
func (f *Formatter) SetTraceBackFormat(format string) *Formatter {
f.TraceBackFormat = format
return f
}
// SetTraceBackSeparator sets the separator used between traceback frames.
func (f *Formatter) SetTraceBackSeparator(separator string) *Formatter {
f.TraceBackSeparator = separator
return f
}
// SetColorOutput enables or disables colorized output.
func (f *Formatter) SetColorOutput(color bool) *Formatter {
f.ColorOutput = color
return f
}
// SetColorOnlyStdout restricts colorized output to stdout and stderr when enabled.
func (f *Formatter) SetColorOnlyStdout(only bool) *Formatter {
f.ColorOnlyStdout = only
return f
}
// FormatMessage applies the formatter template to the provided log record fields.
func (f *Formatter) FormatMessage(level LogLevel, prefix string, tb []*MethodTraceback, messages ...any) string {
if f == nil {
return fmt.Sprint(messages...)
}
output := f.Format
output = strings.ReplaceAll(output, "%t", f.FormatTime(time.Now()))
output = strings.ReplaceAll(output, "%N", prefix)
output = strings.ReplaceAll(output, "%l", level.GetName())
output = strings.ReplaceAll(output, "%L", strings.ToUpper(level.GetName()))
if len(tb) > 0 {
formattedTraceback := f.FormatTraceback(tb[0])
output = strings.ReplaceAll(output, "%b", formattedTraceback)
formattedTraceback = f.FormatTracebacks(tb)
output = strings.ReplaceAll(output, "%B", formattedTraceback)
} else {
output = strings.ReplaceAll(output, "%b", "")
output = strings.ReplaceAll(output, "%B", "")
}
if len(tb) > 0 {
output = strings.ReplaceAll(output, "%M", tb[0].Method)
output = strings.ReplaceAll(output, "%f", tb[0].Filename)
output = strings.ReplaceAll(output, "%n", fmt.Sprintf("%d", tb[0].Line))
output = strings.ReplaceAll(output, "%s", tb[0].Signature)
} else {
output = strings.ReplaceAll(output, "%M", "")
output = strings.ReplaceAll(output, "%f", "")
output = strings.ReplaceAll(output, "%n", "")
output = strings.ReplaceAll(output, "%s", "")
}
if f.JSONTraceBackPath && len(tb) > 0 {
output = strings.ReplaceAll(output, "%p", tb[0].FullPath)
} else {
output = strings.ReplaceAll(output, "%p", "")
}
message := Map(messages, func(m any) string { return fmt.Sprint(m) })
output = strings.ReplaceAll(output, "%m", strings.Join(message, f.MessageSeparator))
return output
}
// ColorizeString wraps a string in the ANSI color sequences defined by the level.
func (f *Formatter) ColorizeString(s string, level LogLevel) string {
builder := NewColorStringBuilder()
if level.fgRgb != nil {
builder.AddForegroundRGB(level.fgRgb)
} else if level.fg256 > 0 {
builder.AddForeground256Color(level.fg256)
} else {
builder.AddFgColor(level.fg)
}
if level.bgRgb != nil {
builder.AddBackgroundRGB(level.bgRgb)
} else if level.bg256 > 0 {
builder.AddBackground256Color(level.bg256)
} else {
builder.AddBgColor(level.bg)
}
for _, attr := range level.attrs {
builder.AddAttribute(attr)
}
return builder.AddText(s).AddReset().String()
}
// FormatTime formats a timestamp using the formatter timestamp layout.
func (f *Formatter) FormatTime(t time.Time) string {
if f.TimeStampFormat == "" {
return t.Format(time.RFC3339)
}
return t.Format(StrftimeToGo(f.TimeStampFormat))
}
// FormatTraceback formats a single traceback frame.
func (f *Formatter) FormatTraceback(traceback *MethodTraceback) string {
formattedTraceback := f.TraceBackFormat
if f.JSONTraceBackPath {
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%p", traceback.FullPath)
} else {
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%p", "")
}
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%M", traceback.Method)
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%f", traceback.Filename)
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%n", fmt.Sprintf("%d", traceback.Line))
formattedTraceback = strings.ReplaceAll(formattedTraceback, "%s", traceback.Signature)
return formattedTraceback
}
// FormatTracebacks formats and joins multiple traceback frames.
func (f *Formatter) FormatTracebacks(traceback []*MethodTraceback) string {
var formattedTraceback []string
for _, frame := range traceback {
formattedTraceback = append(formattedTraceback, f.FormatTraceback(frame))
}
return strings.Join(formattedTraceback, f.TraceBackSeparator)
}
+1 -9
View File
@@ -1,11 +1,3 @@
module git.scuroneko.dev/scuroneko/slog module git.scuroneko.dev/scuroneko/sneklog/v2
go 1.26 go 1.26
require github.com/fatih/color v1.19.0
require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
golang.org/x/sys v0.43.0 // indirect
)
-15
View File
@@ -1,15 +0,0 @@
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+47
View File
@@ -0,0 +1,47 @@
package sneklog
import "net/http"
// HTTP method-specific log levels.
var (
HTTPGetLevel = NewInfoLogLevelWithColors("get", FgGreen, BgNone)
HTTPHeadLevel = NewInfoLogLevelWithColors("head", FgCyan, BgNone)
HTTPPostLevel = NewInfoLogLevelWithColors("post", FgBlue, BgNone)
HTTPPutLevel = NewInfoLogLevelWithColors("put", FgYellow, BgNone)
HTTPPatchLevel = NewInfoLogLevelWithColors("patch", FgMagenta, BgNone)
HTTPDeleteLevel = NewInfoLogLevelWithColors("delete", FgRed, BgNone)
)
// Additional HTTP method-specific log levels.
var (
HTTPOptionsLevel = NewDebugLogLevelWithColors("options", FgWhite, BgNone)
HTTPConnectLevel = NewDebugLogLevelWithColors("connect", FgCyan, BgNone)
HTTPTraceLevel = NewDebugLogLevelWithColors("trace", FgWhite, BgNone)
HTTPUnknownLevel = NewDebugLogLevelWithColors("http", FgWhite, BgNone)
)
// LogLevelForMethod returns the predefined log level associated with an HTTP method.
func LogLevelForMethod(method string) LogLevel {
switch method {
case http.MethodGet:
return HTTPGetLevel
case http.MethodHead:
return HTTPHeadLevel
case http.MethodPost:
return HTTPPostLevel
case http.MethodPut:
return HTTPPutLevel
case http.MethodPatch:
return HTTPPatchLevel
case http.MethodDelete:
return HTTPDeleteLevel
case http.MethodOptions:
return HTTPOptionsLevel
case http.MethodConnect:
return HTTPConnectLevel
case http.MethodTrace:
return HTTPTraceLevel
default:
return HTTPUnknownLevel
}
}
+34 -42
View File
@@ -1,4 +1,4 @@
package slog package sneklog
import ( import (
"fmt" "fmt"
@@ -7,125 +7,117 @@ import (
// Infof logs a formatted info message. // Infof logs a formatted info message.
func (l *Logger) Infof(format string, args ...any) { func (l *Logger) Infof(format string, args ...any) {
l.print(INFO, fmt.Sprintf(format, args...)) l.Print(INFO, fmt.Sprintf(format, args...))
} }
// Info logs an info message. // Info logs an info message.
func (l *Logger) Info(m ...any) { func (l *Logger) Info(m ...any) {
l.print(INFO, m...) l.Print(INFO, m...)
} }
// Infoln logs an info message and appends a newline semantic for writers that need it. // Infoln logs an info message and appends a newline semantic for writers that need it.
func (l *Logger) Infoln(m ...any) { func (l *Logger) Infoln(m ...any) {
l.println(INFO, m...) l.Println(INFO, m...)
} }
// Warnf logs a formatted warning message. // Warnf logs a formatted warning message.
func (l *Logger) Warnf(format string, args ...any) { func (l *Logger) Warnf(format string, args ...any) {
l.print(WARN, fmt.Sprintf(format, args...)) l.Print(WARN, fmt.Sprintf(format, args...))
} }
// Warn logs a warning message. // Warn logs a warning message.
func (l *Logger) Warn(m ...any) { func (l *Logger) Warn(m ...any) {
l.print(WARN, m...) l.Print(WARN, m...)
} }
// Warnln logs a warning message with newline semantic. // Warnln logs a warning message with newline semantic.
func (l *Logger) Warnln(m ...any) { func (l *Logger) Warnln(m ...any) {
l.println(WARN, m...) l.Println(WARN, m...)
} }
// Errorf logs a formatted error message. // Errorf logs a formatted error message.
func (l *Logger) Errorf(format string, args ...any) { func (l *Logger) Errorf(format string, args ...any) {
l.print(ERROR, fmt.Sprintf(format, args...)) l.Print(ERROR, fmt.Sprintf(format, args...))
} }
// Error logs an error message. // Error logs an error message.
func (l *Logger) Error(m ...any) { func (l *Logger) Error(m ...any) {
l.print(ERROR, m...) l.Print(ERROR, m...)
} }
// Errorln logs an error message with newline semantic. // Errorln logs an error message with newline semantic.
func (l *Logger) Errorln(m ...any) { func (l *Logger) Errorln(m ...any) {
l.println(ERROR, m...) l.Println(ERROR, m...)
} }
// Fatalf logs a formatted fatal message and exits the process with code 1. // Fatalf logs a formatted fatal message and exits the process with code 1.
func (l *Logger) Fatalf(format string, args ...any) { func (l *Logger) Fatalf(format string, args ...any) {
l.print(FATAL, fmt.Sprintf(format, args...)) l.Print(FATAL, fmt.Sprintf(format, args...))
os.Exit(1) os.Exit(1)
} }
// Fatal logs a fatal message and exits the process with code 1. // Fatal logs a fatal message and exits the process with code 1.
func (l *Logger) Fatal(m ...any) { func (l *Logger) Fatal(m ...any) {
l.print(FATAL, m...) l.Print(FATAL, m...)
os.Exit(1) os.Exit(1)
} }
// Fatalln logs a fatal message with newline semantic and exits the process with code 1. // Fatalln logs a fatal message with newline semantic and exits the process with code 1.
func (l *Logger) Fatalln(m ...any) { func (l *Logger) Fatalln(m ...any) {
l.println(FATAL, m...) l.Println(FATAL, m...)
os.Exit(1) os.Exit(1)
} }
// Debugf logs a formatted debug message. // Debugf logs a formatted debug message.
func (l *Logger) Debugf(format string, args ...any) { func (l *Logger) Debugf(format string, args ...any) {
l.print(DEBUG, fmt.Sprintf(format, args...)) l.Print(DEBUG, fmt.Sprintf(format, args...))
} }
// Debug logs a debug message. // Debug logs a debug message.
func (l *Logger) Debug(m ...any) { func (l *Logger) Debug(m ...any) {
l.print(DEBUG, m...) l.Print(DEBUG, m...)
} }
// Debugln logs a debug message with newline semantic. // Debugln logs a debug message with newline semantic.
func (l *Logger) Debugln(m ...any) { func (l *Logger) Debugln(m ...any) {
l.println(DEBUG, m...) l.Println(DEBUG, m...)
} }
// Write message without trailing "\n" func (l *Logger) print(level LogLevel, newline bool, m ...any) {
// Good for database if l.threshold && l.level.th < level.th {
func (l *Logger) print(level LogLevel, m ...any) { return
if l.level.n < level.n { } else if l.level.n < level.n {
return return
} }
if len(l.writers) == 0 { if len(l.writers) == 0 {
return return
} }
tb := getFullTraceback(0) tb := getFullTraceback(1)
messages := l.replaceAll(m...)
if newline {
messages = append(messages, any("\n"))
}
for _, writer := range l.writers { for _, writer := range l.writers {
if writer == nil { if writer == nil {
continue continue
} }
err := writer.Print(level, l.prefix, tb, l.replaceAll(m...)...) err := writer.Print(level, l.prefix, tb, messages...)
if err != nil { if err != nil {
l.reportWriterError(err) l.reportWriterError(err)
} }
} }
} }
// Docker requires "\n" at end to write to log. // Print logs a message without appending a trailing newline semantic.
// print not work for docker, otherwise it will work and write into stdout func (l *Logger) Print(level LogLevel, m ...any) { l.print(level, false, m...) }
func (l *Logger) println(level LogLevel, m ...any) {
if l.level.n < level.n {
return
}
if len(l.writers) == 0 {
return
}
tb := getFullTraceback(0) // Println logs a message and appends a trailing newline semantic for writers that need it.
messages := append(append(make([]any, 0, len(m)+1), m...), "\n") func (l *Logger) Println(level LogLevel, m ...any) { l.print(level, true, m...) }
for _, writer := range l.writers {
if writer == nil { // Printf formats according to a format specifier and logs the resulting message.
continue func (l *Logger) Printf(level LogLevel, format string, args ...any) {
} l.print(level, false, fmt.Sprintf(format, args...))
err := writer.Print(level, l.prefix, tb, l.replaceAll(messages...)...)
if err != nil {
l.reportWriterError(err)
}
}
} }
// reportWriterError writes internal writer failures directly to stderr to avoid // reportWriterError writes internal writer failures directly to stderr to avoid
+269
View File
@@ -0,0 +1,269 @@
package sneklog
// Predefined log levels.
var (
INFO = NewInfoLogLevelWithColors("info", FgWhite, BgNone)
WARN = NewWarnLogLevelWithColors("warn", FgHiYellow, BgNone)
ERROR = NewErrorLogLevelWithColors("error", FgHiRed, BgNone)
FATAL = NewFatalLogLevelWithColors("fatal", FgRed, BgNone)
DEBUG = NewDebugLogLevelWithColors("debug", FgGreen, BgNone)
)
// LogLevel describes a logging severity.
type LogLevel struct {
n uint8
th uint8
t string
fg FgColor
fg256 FgColor256
fgRgb FgColorRGB
bg BgColor
bg256 BgColor256
bgRgb BgColorRGB
attrs []Attribute
}
// NewLogLevel creates a log level without predefined colors.
// Deprecated: use NewThresholdLogLevel. This function will be removed in v3.
func NewLogLevel(index uint8, name string) LogLevel {
return NewLogLevelWithColors(index, name, 0, 0)
}
// NewLogLevelWithColors creates a log level with ANSI foreground and background colors.
// Deprecated: use NewThresholdLogLevelWithColors. This function will be removed in v3.
func NewLogLevelWithColors(index uint8, name string, fg FgColor, bg BgColor) LogLevel {
return LogLevel{n: index, t: name, fg: fg, bg: bg, attrs: []Attribute{}}
}
// NewThresholdLogLevel creates a log level with both a legacy severity index and
// a threshold value used when Logger.SetThresholdMode(true) is enabled.
func NewThresholdLogLevel(index, th uint8, name string) LogLevel {
return NewThresholdLogLevelWithColors(index, th, name, FgNone, BgNone)
}
// NewThresholdLogLevelWithColors creates a log level with both a legacy
// severity index and a threshold value, plus ANSI foreground and background
// colors.
func NewThresholdLogLevelWithColors(index, th uint8, name string, fg FgColor, bg BgColor) LogLevel {
return LogLevel{n: index, th: th, t: name, fg: fg, bg: bg, attrs: []Attribute{}}
}
// NewInfoLogLevel creates an info-level log level without predefined colors.
func NewInfoLogLevel(name string) LogLevel { return NewThresholdLogLevel(0, 10, name) }
// NewInfoLogLevelWithColors creates an info-level log level with ANSI colors.
func NewInfoLogLevelWithColors(name string, fg FgColor, bg BgColor) LogLevel {
return NewThresholdLogLevelWithColors(0, 10, name, fg, bg)
}
// NewWarnLogLevel creates a warn-level log level without predefined colors.
func NewWarnLogLevel(name string) LogLevel { return NewThresholdLogLevel(1, 20, name) }
// NewWarnLogLevelWithColors creates a warn-level log level with ANSI colors.
func NewWarnLogLevelWithColors(name string, fg FgColor, bg BgColor) LogLevel {
return NewThresholdLogLevelWithColors(1, 20, name, fg, bg)
}
// NewErrorLogLevel creates an error-level log level without predefined colors.
func NewErrorLogLevel(name string) LogLevel { return NewThresholdLogLevel(2, 30, name) }
// NewErrorLogLevelWithColors creates an error-level log level with ANSI colors.
func NewErrorLogLevelWithColors(name string, fg FgColor, bg BgColor) LogLevel {
return NewThresholdLogLevelWithColors(2, 30, name, fg, bg)
}
// NewFatalLogLevel creates a fatal-level log level without predefined colors.
func NewFatalLogLevel(name string) LogLevel { return NewThresholdLogLevel(3, 40, name) }
// NewFatalLogLevelWithColors creates a fatal-level log level with ANSI colors.
func NewFatalLogLevelWithColors(name string, fg FgColor, bg BgColor) LogLevel {
return NewThresholdLogLevelWithColors(3, 40, name, fg, bg)
}
// NewDebugLogLevel creates a debug-level log level without predefined colors.
func NewDebugLogLevel(name string) LogLevel { return NewThresholdLogLevel(4, 0, name) }
// NewDebugLogLevelWithColors creates a debug-level log level with ANSI colors.
func NewDebugLogLevelWithColors(name string, fg FgColor, bg BgColor) LogLevel {
return NewThresholdLogLevelWithColors(4, 0, name, fg, bg)
}
// GetName returns the lowercase textual representation of the level.
func (l *LogLevel) GetName() string { return l.t }
// SetFgColor sets the ANSI foreground color and clears other foreground color modes.
// Deprecated: use SetForegroundColor. This method will be removed in v3.
func (l *LogLevel) SetFgColor(color FgColor) *LogLevel {
return l.SetForegroundColor(color)
}
// GetFgColor returns the ANSI foreground color for the level.
// Deprecated: use GetForegroundColor. This method will be removed in v3.
func (l *LogLevel) GetFgColor() FgColor { return l.GetForegroundColor() }
// SetForegroundColor sets the ANSI foreground color and clears other foreground color modes.
func (l *LogLevel) SetForegroundColor(color FgColor) *LogLevel {
l.fg = color
l.fg256 = 0
l.fgRgb = nil
return l
}
// GetForegroundColor returns the ANSI foreground color for the level.
func (l *LogLevel) GetForegroundColor() FgColor { return l.fg }
// SetForeground256Color sets the 256-color foreground and clears other foreground color modes.
func (l *LogLevel) SetForeground256Color(color FgColor256) *LogLevel {
l.fg = 0
l.fg256 = color
l.fgRgb = nil
return l
}
// GetForeground256Color returns the configured 256-color foreground value.
func (l *LogLevel) GetForeground256Color() FgColor256 { return l.fg256 }
// SetForegroundRGB sets the RGB foreground color and clears other foreground color modes.
func (l *LogLevel) SetForegroundRGB(r, g, b uint8) *LogLevel {
l.fg = 0
l.fg256 = 0
l.fgRgb = FgColorRGB{r, g, b}
return l
}
// GetForegroundRGB returns the configured RGB foreground value.
func (l *LogLevel) GetForegroundRGB() FgColorRGB { return l.fgRgb }
// SetBgColor sets the ANSI background color and clears other background color modes.
// Deprecated: use SetBackgroundColor. This method will be removed in v3.
func (l *LogLevel) SetBgColor(color BgColor) *LogLevel {
return l.SetBackgroundColor(color)
}
// GetBgColor returns the ANSI background color for the level.
// Deprecated: use GetBackgroundColor. This method will be removed in v3.
func (l *LogLevel) GetBgColor() BgColor { return l.GetBackgroundColor() }
// SetBackgroundColor sets the ANSI background color and clears other background color modes.
func (l *LogLevel) SetBackgroundColor(color BgColor) *LogLevel {
l.bg = color
l.bg256 = 0
l.bgRgb = nil
return l
}
// GetBackgroundColor returns the ANSI background color for the level.
func (l *LogLevel) GetBackgroundColor() BgColor { return l.bg }
// SetBackground256Color sets the 256-color background and clears other background color modes.
func (l *LogLevel) SetBackground256Color(color BgColor256) *LogLevel {
l.bg = 0
l.bg256 = color
l.bgRgb = nil
return l
}
// GetBackground256Color returns the configured 256-color background value.
func (l *LogLevel) GetBackground256Color() BgColor256 { return l.bg256 }
// SetBackgroundRGB sets the RGB background color and clears other background color modes.
func (l *LogLevel) SetBackgroundRGB(r, g, b uint8) *LogLevel {
l.bg = 0
l.bg256 = 0
l.bgRgb = BgColorRGB{r, g, b}
return l
}
// GetBackgroundRGB returns the configured RGB background value.
func (l *LogLevel) GetBackgroundRGB() BgColorRGB { return l.bgRgb }
// AddAttribute appends an ANSI text attribute to the level.
func (l *LogLevel) AddAttribute(a Attribute) *LogLevel {
l.attrs = append(l.attrs, a)
return l
}
// RemoveAttribute removes all matching ANSI text attributes from the level.
func (l *LogLevel) RemoveAttribute(a Attribute) *LogLevel {
attrs := make([]Attribute, 0)
for _, attr := range l.attrs {
if attr != a {
attrs = append(attrs, attr)
}
}
l.attrs = attrs
return l
}
// SetAttributes replaces the level attributes with a copy of the provided slice.
func (l *LogLevel) SetAttributes(a []Attribute) *LogLevel {
attrs := make([]Attribute, len(a))
copy(attrs, a)
l.attrs = attrs
return l
}
// GetAttributes returns a copy of the configured ANSI text attributes.
func (l *LogLevel) GetAttributes() []Attribute {
attrs := make([]Attribute, len(l.attrs))
copy(attrs, l.attrs)
return attrs
}
// SameLevel reports whether two log levels have the same severity index.
// Deprecated: use SameThreshold for threshold-only comparisons or Equal for the
// full level configuration. SameLevel remains severity-only for backward
// compatibility and ignores threshold values.
func (l *LogLevel) SameLevel(other LogLevel) bool {
return l.n == other.n
}
// SameThreshold reports whether two log levels have the same threshold value.
func (l *LogLevel) SameThreshold(other LogLevel) bool {
return l.th == other.th
}
// Equal reports whether two log levels have identical configuration.
func (l *LogLevel) Equal(other LogLevel) bool {
if l.t != other.t || l.n != other.n || l.th != other.th {
return false
}
if l.fg != other.fg || l.bg != other.bg {
return false
}
if l.fg256 != other.fg256 || l.bg256 != other.bg256 {
return false
}
if len(l.attrs) != len(other.attrs) {
return false
}
for i := range l.attrs {
if l.attrs[i] != other.attrs[i] {
return false
}
}
if len(l.fgRgb) != len(other.fgRgb) {
return false
}
for i := range l.fgRgb {
if l.fgRgb[i] != other.fgRgb[i] {
return false
}
}
if len(l.bgRgb) != len(other.bgRgb) {
return false
}
for i := range l.bgRgb {
if l.bgRgb[i] != other.bgRgb[i] {
return false
}
}
return true
}
+57 -106
View File
@@ -1,48 +1,12 @@
package slog package sneklog
import ( import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
"time"
"github.com/fatih/color"
) )
type replacer struct {
old string
new string
}
func (r replacer) replace(s string) string {
return strings.ReplaceAll(s, r.old, r.new)
}
// Logger routes log records to one or more configured writers.
type Logger struct {
prefix string
level LogLevel
writers []LoggerWriter
replacers []replacer
printTraceback bool
printTime bool
jsonPretty bool
}
// LogLevel describes a logging severity.
type LogLevel struct {
n uint8
t string
c color.Attribute
}
// GetName returns the lowercase textual representation of the level.
func (l *LogLevel) GetName() string {
return l.t
}
// MethodTraceback describes a single stack frame attached to a log entry. // MethodTraceback describes a single stack frame attached to a log entry.
type MethodTraceback struct { type MethodTraceback struct {
Method string `json:"method"` Method string `json:"method"`
@@ -52,55 +16,81 @@ type MethodTraceback struct {
FullPath string `json:"fullPath"` FullPath string `json:"fullPath"`
} }
// Predefined log levels. // Logger routes log records to one or more configured writers.
var ( type Logger struct {
INFO = LogLevel{n: 0, t: "info", c: color.FgWhite} prefix string
WARN = LogLevel{n: 1, t: "warn", c: color.FgHiYellow} level LogLevel
ERROR = LogLevel{n: 2, t: "error", c: color.FgHiRed}
FATAL = LogLevel{n: 3, t: "fatal", c: color.FgRed} writers []LoggerWriter
DEBUG = LogLevel{n: 4, t: "debug", c: color.FgGreen} replacers []replacer
)
jsonPretty bool
threshold bool
}
// CreateLogger creates a logger with default settings. // CreateLogger creates a logger with default settings.
// Deprecated: use NewLogger. This method will be removed in v3.
func CreateLogger() *Logger { func CreateLogger() *Logger {
return &Logger{ return &Logger{
prefix: "LOG", prefix: "LOG",
level: FATAL, level: FATAL,
printTraceback: false, }
printTime: true, }
// NewLogger creates a logger with default settings.
func NewLogger() *Logger {
return &Logger{
prefix: "LOG",
level: FATAL,
} }
} }
// Prefix sets the record prefix and returns the logger for chaining. // Prefix sets the record prefix and returns the logger for chaining.
// Deprecated: use SetName. This method will be removed in v3.
func (l *Logger) Prefix(prefix string) *Logger { func (l *Logger) Prefix(prefix string) *Logger {
l.prefix = prefix l.prefix = prefix
return l return l
} }
// SetName sets the record prefix and returns the logger for chaining.
func (l *Logger) SetName(name string) *Logger {
l.prefix = name
return l
}
// Level sets the maximum enabled level and returns the logger for chaining. // Level sets the maximum enabled level and returns the logger for chaining.
// Deprecated: use SetLevel. This method will be removed in v3.
func (l *Logger) Level(level LogLevel) *Logger { func (l *Logger) Level(level LogLevel) *Logger {
l.level = level l.level = level
return l return l
} }
// PrintTraceback enables traceback output for text writers. // SetLevel sets the maximum enabled level and returns the logger for chaining.
func (l *Logger) PrintTraceback(b bool) *Logger { func (l *Logger) SetLevel(level LogLevel) *Logger {
l.printTraceback = b l.level = level
return l return l
} }
// PrintTime enables timestamps for text writers. // JsonPretty enables or disables indented JSON output for JSON writers created by the logger.
func (l *Logger) PrintTime(b bool) *Logger { // Deprecated: use SetJSONPretty. This method will be removed in v3.
l.printTime = b
return l
}
// JsonPretty enables indented JSON output for JSON writers.
func (l *Logger) JsonPretty(b bool) *Logger { func (l *Logger) JsonPretty(b bool) *Logger {
l.jsonPretty = b l.jsonPretty = b
return l return l
} }
// SetJSONPretty enables or disables indented output for JSON writers created by the logger.
func (l *Logger) SetJSONPretty(b bool) *Logger {
l.jsonPretty = b
return l
}
// SetThresholdMode switches Logger.Print filtering from legacy severity-index
// ordering to LogLevel threshold ordering.
func (l *Logger) SetThresholdMode(b bool) *Logger {
l.threshold = b
return l
}
// AddWriters appends multiple writers to the logger. // AddWriters appends multiple writers to the logger.
func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger { func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger {
l.writers = append(l.writers, writers...) l.writers = append(l.writers, writers...)
@@ -108,6 +98,7 @@ func (l *Logger) AddWriters(writers ...LoggerWriter) *Logger {
} }
// AddWriter appends a single writer to the logger. // AddWriter appends a single writer to the logger.
// Deprecated: use AddWriters. This method will be removed in v3.
func (l *Logger) AddWriter(writer LoggerWriter) *Logger { func (l *Logger) AddWriter(writer LoggerWriter) *Logger {
l.writers = append(l.writers, writer) l.writers = append(l.writers, writer)
return l return l
@@ -144,18 +135,14 @@ func (l *Logger) Close() error {
} }
// CreateTextWriter wraps an external writer with the logger text settings. // CreateTextWriter wraps an external writer with the logger text settings.
func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { func (l *Logger) CreateTextWriter(w io.Writer) *LoggerTextWriter { return CreateTextWriter(w) }
return CreateTextWriter(w, l.printTraceback, l.printTime)
}
// CreateTextStdoutWriter creates a non-owning text writer for os.Stdout. // CreateTextStdoutWriter creates a non-owning text writer for os.Stdout.
func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { func (l *Logger) CreateTextStdoutWriter() *LoggerTextWriter { return CreateTextStdoutWriter() }
return CreateTextStdoutWriter(l.printTraceback, l.printTime)
}
// CreateTextFileWriter creates an owning text writer for a file. // CreateTextFileWriter creates an owning text writer for a file.
func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) { func (l *Logger) CreateTextFileWriter(filename string) (*LoggerTextWriter, error) {
return CreateTextFileWriter(filename, l.printTraceback, l.printTime) return CreateTextFileWriter(filename)
} }
// CreateJsonWriter wraps an external writer with the logger JSON settings. // CreateJsonWriter wraps an external writer with the logger JSON settings.
@@ -173,6 +160,12 @@ func (l *Logger) CreateJsonFileWriter(filename string) (*LoggerJsonWriter, error
return CreateJsonFileWriter(filename, l.jsonPretty) return CreateJsonFileWriter(filename, l.jsonPretty)
} }
type replacer struct {
old string
new string
}
func (r replacer) replace(s string) string { return strings.ReplaceAll(s, r.old, r.new) }
func (l *Logger) replace(s string) string { func (l *Logger) replace(s string) string {
out := s out := s
for _, repl := range l.replacers { for _, repl := range l.replacers {
@@ -180,7 +173,6 @@ func (l *Logger) replace(s string) string {
} }
return out return out
} }
func (l *Logger) replaceAll(messages ...any) []any { func (l *Logger) replaceAll(messages ...any) []any {
if len(l.replacers) == 0 { if len(l.replacers) == 0 {
return messages return messages
@@ -191,44 +183,3 @@ func (l *Logger) replaceAll(messages ...any) []any {
} }
return out return out
} }
// FormatTime converts time to the package text log timestamp format.
func FormatTime(t time.Time) string {
return fmt.Sprintf("%02d.%02d.%02d %02d:%02d:%02d", t.Day(), t.Month(), t.Year(), t.Hour(), t.Minute(), t.Second())
}
// FormatTraceback converts a traceback frame to a compact string.
func FormatTraceback(mt *MethodTraceback) string {
return fmt.Sprintf("%s:%s:%d", mt.Filename, mt.Method, mt.Line)
}
// FormatFullTraceback joins multiple traceback frames into one string.
func FormatFullTraceback(tracebacks []*MethodTraceback) string {
formatted := make([]string, 0)
for _, tb := range tracebacks {
formatted = append(formatted, FormatTraceback(tb))
}
return strings.Join(formatted, "->")
}
// BuildString renders a text log record using the provided settings.
func BuildString(level LogLevel, prefix string, printTime, printTraceback bool, messages ...any) string {
args := []string{
fmt.Sprintf("[%s]", prefix),
fmt.Sprintf("[%s]", strings.ToUpper(level.t)),
}
if printTraceback {
args = append(args, fmt.Sprintf("[%s]", FormatTraceback(getTraceback())))
}
if printTime {
args = append(args, fmt.Sprintf("[%s]", FormatTime(time.Now())))
}
m := Map(messages, func(t any) string {
return fmt.Sprint(t)
})
s := fmt.Sprintf("%s %s", strings.Join(args, " "), strings.Join(m, " "))
return s
}
+470 -7
View File
@@ -1,9 +1,10 @@
package slog package sneklog
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -17,6 +18,9 @@ type stubLoggerWriter struct {
messages []any messages []any
} }
func (w *stubLoggerWriter) Formatter() *Formatter {
return &Formatter{}
}
func (w *stubLoggerWriter) Close() error { func (w *stubLoggerWriter) Close() error {
w.closeCalls++ w.closeCalls++
return w.closeErr return w.closeErr
@@ -97,8 +101,182 @@ func TestLoggerPreservesMessageTypesWithoutReplacers(t *testing.T) {
} }
} }
func TestLoggerPrintfFormatsMessage(t *testing.T) {
writer := &stubLoggerWriter{}
logger := CreateLogger().
SetLevel(DEBUG).
AddWriter(writer)
logger.Printf(INFO, "status=%d %s", 200, "ok")
if writer.printCalls != 1 {
t.Fatalf("Printf() should write once, got %d calls", writer.printCalls)
}
if len(writer.messages) != 1 {
t.Fatalf("Printf() should produce one formatted message, got %d parts", len(writer.messages))
}
if got, ok := writer.messages[0].(string); !ok || got != "status=200 ok" {
t.Fatalf("Printf() should format message, got %#v", writer.messages)
}
}
func TestLoggerUsesSeverityOrderingWhenThresholdModeIsDisabled(t *testing.T) {
writer := &stubLoggerWriter{}
logger := CreateLogger().
SetLevel(NewThresholdLogLevel(1, 0, "warn")).
AddWriter(writer)
logger.Print(NewThresholdLogLevel(0, 100, "info"))
logger.Print(NewThresholdLogLevel(2, 0, "error"))
if writer.printCalls != 1 {
t.Fatalf("severity mode should allow only lower-or-equal severity levels, got %d writes", writer.printCalls)
}
}
func TestLoggerUsesThresholdOrderingWhenThresholdModeIsEnabled(t *testing.T) {
writer := &stubLoggerWriter{}
logger := CreateLogger().
SetLevel(NewThresholdLogLevel(4, 20, "custom")).
SetThresholdMode(true).
AddWriter(writer)
logger.Print(NewThresholdLogLevel(4, 10, "debug"))
logger.Print(NewThresholdLogLevel(0, 30, "info"))
if writer.printCalls != 1 {
t.Fatalf("threshold mode should allow only entries at or below the configured threshold, got %d writes", writer.printCalls)
}
}
func TestDeprecatedLevelMethodKeepsLegacySeverityBehavior(t *testing.T) {
writer := &stubLoggerWriter{}
logger := CreateLogger().
Level(FATAL).
AddWriter(writer)
logger.Print(INFO, "info")
logger.Print(WARN, "warn")
logger.Print(ERROR, "error")
logger.Print(FATAL, "fatal")
logger.Print(DEBUG, "debug")
if writer.printCalls != 4 {
t.Fatalf("Level(FATAL) should keep legacy behavior and allow INFO/WARN/ERROR/FATAL only, got %d writes", writer.printCalls)
}
}
func TestSameLevelIgnoresThresholdDifferencesForCompatibility(t *testing.T) {
legacy := NewLogLevel(1, "warn")
threshold := NewThresholdLogLevel(1, 20, "warn")
if !legacy.SameLevel(threshold) {
t.Fatalf("SameLevel() should continue comparing only severity index")
}
if legacy.Equal(threshold) {
t.Fatalf("Equal() should distinguish levels with different threshold configuration")
}
}
func TestSameThresholdComparesOnlyThresholdValue(t *testing.T) {
first := NewThresholdLogLevel(1, 20, "warn")
second := NewThresholdLogLevel(9, 20, "custom")
third := NewThresholdLogLevel(1, 30, "warn")
if !first.SameThreshold(second) {
t.Fatalf("SameThreshold() should ignore severity and compare threshold only")
}
if first.SameThreshold(third) {
t.Fatalf("SameThreshold() should detect different threshold values")
}
}
func TestLogLevelForMethodReturnsExpectedLevels(t *testing.T) {
tests := []struct {
name string
method string
want LogLevel
}{
{name: "get", method: http.MethodGet, want: HTTPGetLevel},
{name: "head", method: http.MethodHead, want: HTTPHeadLevel},
{name: "post", method: http.MethodPost, want: HTTPPostLevel},
{name: "put", method: http.MethodPut, want: HTTPPutLevel},
{name: "patch", method: http.MethodPatch, want: HTTPPatchLevel},
{name: "delete", method: http.MethodDelete, want: HTTPDeleteLevel},
{name: "options", method: http.MethodOptions, want: HTTPOptionsLevel},
{name: "connect", method: http.MethodConnect, want: HTTPConnectLevel},
{name: "trace", method: http.MethodTrace, want: HTTPTraceLevel},
{name: "unknown", method: "PROPFIND", want: HTTPUnknownLevel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := LogLevelForMethod(tt.method)
if got.n != tt.want.n || got.t != tt.want.t || got.fg != tt.want.fg || got.bg != tt.want.bg {
t.Fatalf("LogLevelForMethod(%q) = %#v, want %#v", tt.method, got, tt.want)
}
})
}
}
func TestNewLevelConstructorsSetExpectedSeverity(t *testing.T) {
tests := []struct {
name string
got LogLevel
want LogLevel
wantTh uint8
}{
{name: "info", got: NewInfoLogLevel("custom"), want: INFO, wantTh: 10},
{name: "warn", got: NewWarnLogLevel("custom"), want: WARN, wantTh: 20},
{name: "error", got: NewErrorLogLevel("custom"), want: ERROR, wantTh: 30},
{name: "fatal", got: NewFatalLogLevel("custom"), want: FATAL, wantTh: 40},
{name: "debug", got: NewDebugLogLevel("custom"), want: DEBUG, wantTh: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got.n != tt.want.n {
t.Fatalf("%s severity = %d, want %d", tt.name, tt.got.n, tt.want.n)
}
if tt.got.th != tt.wantTh {
t.Fatalf("%s threshold = %d, want %d", tt.name, tt.got.th, tt.wantTh)
}
if tt.got.GetName() != "custom" {
t.Fatalf("%s constructor should preserve name, got %q", tt.name, tt.got.GetName())
}
})
}
}
func TestNewLevelConstructorsWithColorsSetExpectedFields(t *testing.T) {
info := NewInfoLogLevelWithColors("info-custom", FgBlue, BgYellow)
if info.n != INFO.n || info.GetName() != "info-custom" || info.fg != FgBlue || info.bg != BgYellow {
t.Fatalf("NewInfoLogLevelWithColors() = %#v", info)
}
warn := NewWarnLogLevelWithColors("warn-custom", FgMagenta, BgCyan)
if warn.n != WARN.n || warn.GetName() != "warn-custom" || warn.fg != FgMagenta || warn.bg != BgCyan {
t.Fatalf("NewWarnLogLevelWithColors() = %#v", warn)
}
errLevel := NewErrorLogLevelWithColors("error-custom", FgRed, BgWhite)
if errLevel.n != ERROR.n || errLevel.GetName() != "error-custom" || errLevel.fg != FgRed || errLevel.bg != BgWhite {
t.Fatalf("NewErrorLogLevelWithColors() = %#v", errLevel)
}
fatal := NewFatalLogLevelWithColors("fatal-custom", FgHiRed, BgBlack)
if fatal.n != FATAL.n || fatal.GetName() != "fatal-custom" || fatal.fg != FgHiRed || fatal.bg != BgBlack {
t.Fatalf("NewFatalLogLevelWithColors() = %#v", fatal)
}
debug := NewDebugLogLevelWithColors("debug-custom", FgGreen, BgDefault)
if debug.n != DEBUG.n || debug.GetName() != "debug-custom" || debug.fg != FgGreen || debug.bg != BgDefault {
t.Fatalf("NewDebugLogLevelWithColors() = %#v", debug)
}
}
func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) { func TestCreateTextWriterCloseOnNonCloserIsNoOp(t *testing.T) {
writer := CreateTextWriter(&bytes.Buffer{}, false, false) writer := CreateTextWriter(&bytes.Buffer{})
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err) t.Fatalf("Close() error = %v", err)
} }
@@ -113,7 +291,7 @@ func TestCreateTextWriterDoesNotCloseExternalCloser(t *testing.T) {
_ = file.Close() _ = file.Close()
}) })
writer := CreateTextWriter(file, false, false) writer := CreateTextWriter(file)
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err) t.Fatalf("Close() error = %v", err)
} }
@@ -164,6 +342,42 @@ func TestJsonWriterPrintAllowsEmptyMessages(t *testing.T) {
} }
} }
func TestJsonWriterPrintDoesNotAppendNewline(t *testing.T) {
var buf bytes.Buffer
writer := CreateJsonWriter(&buf, false)
if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil {
t.Fatalf("Print() error = %v", err)
}
if bytes.HasSuffix(buf.Bytes(), []byte("\n")) {
t.Fatalf("Print() should not append newline, got %q", buf.String())
}
}
func TestJsonWriterSetJSONEscapeHTML(t *testing.T) {
var escaped bytes.Buffer
escapedWriter := CreateJsonWriter(&escaped, false)
if err := escapedWriter.Print(INFO, "TEST", nil, "<>&"); err != nil {
t.Fatalf("Print() with default formatter error = %v", err)
}
if strings.Contains(escaped.String(), "<>&") {
t.Fatalf("default JSON output should escape HTML-sensitive characters, got %q", escaped.String())
}
var raw bytes.Buffer
rawWriter := CreateJsonWriter(&raw, false).
SetFormatter(NewFormatter().SetFormat("%m").SetJSONEscapeHTML(false))
if err := rawWriter.Print(INFO, "TEST", nil, "<>&"); err != nil {
t.Fatalf("Print() with SetJSONEscapeHTML(false) error = %v", err)
}
if !strings.Contains(raw.String(), "<>&") {
t.Fatalf("SetJSONEscapeHTML(false) should preserve HTML-sensitive characters, got %q", raw.String())
}
}
func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
writer := CreateJsonWriter(&buf, false) writer := CreateJsonWriter(&buf, false)
@@ -176,6 +390,9 @@ func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) {
if !bytes.HasSuffix(data, []byte("\n")) { if !bytes.HasSuffix(data, []byte("\n")) {
t.Fatalf("JSON output should end with newline, got %q", data) t.Fatalf("JSON output should end with newline, got %q", data)
} }
if bytes.HasSuffix(bytes.TrimSuffix(data, []byte("\n")), []byte("\n")) {
t.Fatalf("JSON output should end with exactly one newline, got %q", data)
}
var message LoggerJsonMessage var message LoggerJsonMessage
if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &message); err != nil { if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &message); err != nil {
@@ -186,10 +403,256 @@ func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) {
} }
} }
func TestTextWriterPrintlnDoesNotLeaveTrailingMessageSeparator(t *testing.T) {
var buf bytes.Buffer
formatter := NewFormatter().
SetFormat("%m (%S)").
SetColorOutput(false)
writer := CreateTextWriter(&buf).SetFormatter(formatter)
if err := writer.Print(DEBUG, "TEST", nil, "debug details", "\n"); err != nil {
t.Fatalf("Print() error = %v", err)
}
got := strings.TrimSuffix(buf.String(), "\n")
if got != "debug details (%S)" {
t.Fatalf("println newline marker should not leave trailing message separator, got %q", got)
}
}
func TestTextWriterPrintlnDoesNotLeaveTrailingMessageSeparatorAfterReplacement(t *testing.T) {
var buf bytes.Buffer
formatter := NewFormatter().
SetFormat("%m (%S)").
SetColorOutput(false)
writer := CreateTextWriter(&buf).SetFormatter(formatter)
logger := CreateLogger().
SetLevel(DEBUG).
AddWriter(writer).
AddReplacer("details", "details")
logger.Debugln("debug details")
got := strings.TrimSuffix(buf.String(), "\n")
if got != "debug details (%S)" {
t.Fatalf("println newline marker should not leave trailing message separator after replacements, got %q", got)
}
}
func TestJsonWriterPrintlnDoesNotLeaveTrailingMessageSeparator(t *testing.T) {
var buf bytes.Buffer
writer := CreateJsonWriter(&buf, false)
if err := writer.Print(INFO, "TEST", nil, "hello", "\n"); err != nil {
t.Fatalf("Print() error = %v", err)
}
var message LoggerJsonMessage
if err := json.Unmarshal(bytes.TrimSuffix(buf.Bytes(), []byte("\n")), &message); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if message.Message != "hello" {
t.Fatalf("message should not include trailing separator from newline marker, got %q", message.Message)
}
}
func TestLogLevelForegroundSettersOverridePreviousColorMode(t *testing.T) {
level := NewLogLevel(0, "custom")
formatter := NewFormatter()
level.SetFgColor(FgRed)
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgRed.String()) {
t.Fatalf("expected plain foreground color prefix %q, got %q", FgRed.String(), got)
}
level.SetForeground256Color(FgColor256(123))
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgColor256(123).String()) {
t.Fatalf("expected 256-color foreground prefix %q, got %q", FgColor256(123).String(), got)
}
level.SetForegroundRGB(1, 2, 3)
expectedRGB := NewFgColorRGB(1, 2, 3).String()
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, expectedRGB) {
t.Fatalf("expected RGB foreground prefix %q, got %q", expectedRGB, got)
}
level.SetFgColor(FgBlue)
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, FgBlue.String()) {
t.Fatalf("expected last plain foreground color to win with prefix %q, got %q", FgBlue.String(), got)
}
}
func TestLogLevelBackgroundSettersOverridePreviousColorMode(t *testing.T) {
level := NewLogLevel(0, "custom")
formatter := NewFormatter()
level.SetBgColor(BgRed)
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgRed.String()) {
t.Fatalf("expected plain background color prefix %q, got %q", BgRed.String(), got)
}
level.SetBackground256Color(BgColor256(123))
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgColor256(123).String()) {
t.Fatalf("expected 256-color background prefix %q, got %q", BgColor256(123).String(), got)
}
level.SetBackgroundRGB(1, 2, 3)
expectedRGB := NewBgColorRGB(1, 2, 3).String()
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, expectedRGB) {
t.Fatalf("expected RGB background prefix %q, got %q", expectedRGB, got)
}
level.SetBgColor(BgBlue)
if got := formatter.ColorizeString("x", level); !strings.HasPrefix(got, BgBlue.String()) {
t.Fatalf("expected last plain background color to win with prefix %q, got %q", BgBlue.String(), got)
}
}
func TestLogLevelAttributesAffectColorizedOutput(t *testing.T) {
level := NewLogLevel(0, "custom")
level.AddAttribute(Italic).AddAttribute(Bold)
got := NewFormatter().ColorizeString("x", level)
if !strings.Contains(got, Italic.String()) {
t.Fatalf("expected italic attribute in colorized output, got %q", got)
}
if !strings.Contains(got, Bold.String()) {
t.Fatalf("expected bold attribute in colorized output, got %q", got)
}
}
func TestLogLevelRemoveAttributeRemovesAllMatches(t *testing.T) {
level := NewLogLevel(0, "custom")
level.AddAttribute(Bold).AddAttribute(Italic).AddAttribute(Bold)
level.RemoveAttribute(Bold)
got := level.GetAttributes()
if len(got) != 1 || got[0] != Italic {
t.Fatalf("expected only italic attribute to remain, got %#v", got)
}
}
func TestLogLevelSetAttributesCopiesInputSlice(t *testing.T) {
level := NewLogLevel(0, "custom")
attrs := []Attribute{Bold, Italic}
level.SetAttributes(attrs)
attrs[0] = Underline
got := level.GetAttributes()
if len(got) != 2 || got[0] != Bold || got[1] != Italic {
t.Fatalf("expected SetAttributes to copy input slice, got %#v", got)
}
}
func TestLogLevelGetAttributesReturnsCopy(t *testing.T) {
level := NewLogLevel(0, "custom")
level.SetAttributes([]Attribute{Bold, Italic})
got := level.GetAttributes()
got[0] = Underline
again := level.GetAttributes()
if len(again) != 2 || again[0] != Bold || again[1] != Italic {
t.Fatalf("expected GetAttributes to return a copy, got %#v", again)
}
}
func TestLogLevelDeprecatedForegroundAccessorsRemainCompatible(t *testing.T) {
level := NewLogLevel(0, "custom")
level.SetFgColor(FgBlue)
if got := level.GetFgColor(); got != FgBlue {
t.Fatalf("expected deprecated foreground accessors to round-trip %v, got %v", FgBlue, got)
}
level.SetForegroundColor(FgRed)
if got := level.GetFgColor(); got != FgRed {
t.Fatalf("expected deprecated getter to reflect new foreground setter, got %v", got)
}
}
func TestLogLevelDeprecatedBackgroundAccessorsRemainCompatible(t *testing.T) {
level := NewLogLevel(0, "custom")
level.SetBgColor(BgBlue)
if got := level.GetBgColor(); got != BgBlue {
t.Fatalf("expected deprecated background accessors to round-trip %v, got %v", BgBlue, got)
}
level.SetBackgroundColor(BgRed)
if got := level.GetBgColor(); got != BgRed {
t.Fatalf("expected deprecated getter to reflect new background setter, got %v", got)
}
}
func TestFormatterHandlesEmptyTracebackPlaceholders(t *testing.T) {
formatter := NewFormatter().
SetFormat("%m|%b|%B|%M|%f|%n|%s|%p")
got := formatter.FormatMessage(INFO, "TEST", nil, "hello")
if got != "hello|||||||" {
t.Fatalf("empty traceback placeholders should be empty, got %q", got)
}
}
func TestFormatterDoesNotInterpretPlaceholdersInsideMessages(t *testing.T) {
formatter := NewFormatter().
SetFormat("%L:%m")
message := "literal %L %s %p"
got := formatter.FormatMessage(INFO, "TEST", nil, message)
if got != "INFO:literal %L %s %p" {
t.Fatalf("message placeholder-looking text should stay literal, got %q", got)
}
}
func TestNewFormatterDoesNotMutateDefaultFormatter(t *testing.T) {
formatter := NewFormatter()
formatter.SetFormat("custom")
if DefaultTextFormatter.Format == "custom" {
t.Fatal("NewFormatter() should return a copy, not mutate DefaultTextFormatter")
}
}
func TestTextWriterColorOutputHonorsColorOnlyStdoutFalse(t *testing.T) {
var buf bytes.Buffer
formatter := NewFormatter().
SetFormat("%m").
SetColorOutput(true).
SetColorOnlyStdout(false)
writer := CreateTextWriter(&buf).SetFormatter(formatter)
if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil {
t.Fatalf("Print() error = %v", err)
}
if !strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("ColorOnlyStdout(false) should allow color for external writers, got %q", buf.String())
}
}
func TestTextWriterColorOutputCanBeDisabled(t *testing.T) {
var buf bytes.Buffer
formatter := NewFormatter().
SetFormat("%m").
SetColorOutput(false).
SetColorOnlyStdout(false)
writer := CreateTextWriter(&buf).SetFormatter(formatter)
if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil {
t.Fatalf("Print() error = %v", err)
}
if strings.Contains(buf.String(), "\x1b[") {
t.Fatalf("SetColorOutput(false) should disable color output, got %q", buf.String())
}
}
func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) { func TestCreateTextStdoutWriterDoesNotCloseStdout(t *testing.T) {
stdoutFile := swapStdout(t) stdoutFile := swapStdout(t)
writer := CreateTextStdoutWriter(false, false) writer := CreateTextStdoutWriter()
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
t.Fatalf("Close() error = %v", err) t.Fatalf("Close() error = %v", err)
} }
@@ -210,7 +673,7 @@ func TestCreateJsonStdoutWriterDoesNotCloseStdout(t *testing.T) {
} }
} }
func TestGetFullTracebackSkipsRuntimeAndSlogFrames(t *testing.T) { func TestGetFullTracebackSkipsRuntimeAndSneklogFrames(t *testing.T) {
tracebacks := captureTracebackForTest() tracebacks := captureTracebackForTest()
if len(tracebacks) == 0 { if len(tracebacks) == 0 {
t.Fatal("expected at least one traceback frame") t.Fatal("expected at least one traceback frame")
@@ -225,7 +688,7 @@ func TestGetFullTracebackSkipsRuntimeAndSlogFrames(t *testing.T) {
t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature) t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature)
} }
if _, ok := internalTracebackMethods[tb.Method]; ok { if _, ok := internalTracebackMethods[tb.Method]; ok {
t.Fatalf("internal slog frame should be filtered out, got %s", tb.Signature) t.Fatalf("internal sneklog frame should be filtered out, got %s", tb.Signature)
} }
} }
} }
@@ -242,7 +705,7 @@ func TestGetTracebackReturnsNearestUserFrame(t *testing.T) {
t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature) t.Fatalf("runtime frame should be filtered out, got %s", tb.Signature)
} }
if _, ok := internalTracebackMethods[tb.Method]; ok { if _, ok := internalTracebackMethods[tb.Method]; ok {
t.Fatalf("internal slog frame should be filtered out, got %s", tb.Signature) t.Fatalf("internal sneklog frame should be filtered out, got %s", tb.Signature)
} }
} }
+164
View File
@@ -0,0 +1,164 @@
package sneklog
import "strings"
// Predefined strftime-like time layouts.
const (
Layout = "%m/%d %I:%M:%S%p '%y %z"
ANSIC = "%a %b %e %H:%M:%S %Y"
UnixDate = "%a %b %e %H:%M:%S %Z %Y"
RubyDate = "%a %b %d %H:%M:%S %z %Y"
RFC822 = "%d %b %y %H:%M %Z"
RFC822Z = "%d %b %y %H:%M %z"
RFC850 = "%A, %d-%b-%y %H:%M:%S %Z"
RFC1123 = "%a, %d %b %Y %H:%M:%S %Z"
RFC1123Z = "%a, %d %b %Y %H:%M:%S %z"
RFC3339 = "%Y-%m-%dT%H:%M:%S%:z"
RFC3339Nano = "%Y-%m-%dT%H:%M:%S%#N%:z"
Kitchen = "%-I:%M%p"
Stamp = "%b %e %H:%M:%S"
StampMilli = "%b %e %H:%M:%S.%L"
StampMicro = "%b %e %H:%M:%S.%f"
StampNano = "%b %e %H:%M:%S.%N"
DateTime = "%Y-%m-%d %H:%M:%S"
DateOnly = "%Y-%m-%d"
TimeOnly = "%H:%M:%S"
)
type rule struct {
strf string
goLayout string
}
var strftimeToGoRules = []rule{
// Long/special tokens first.
{"%::z", "Z07:00:00"},
{"%:::z", "Z07"},
{"%:z", "Z07:00"},
{"%#N", ".999999999"},
{"%#f", ".999999"},
{"%#L", ".999"},
{"%-m", "1"},
{"%#m", "1"},
{"%-d", "2"},
{"%#d", "2"},
{"%-H", "15"},
{"%#H", "15"},
{"%-I", "3"},
{"%#I", "3"},
{"%-M", "4"},
{"%#M", "4"},
{"%-S", "5"},
{"%#S", "5"},
{"%%", "%"},
{"%Y", "2006"},
{"%y", "06"},
{"%B", "January"},
{"%b", "Jan"},
{"%h", "Jan"},
{"%m", "01"},
{"%d", "02"},
{"%e", "_2"},
{"%A", "Monday"},
{"%a", "Mon"},
{"%H", "15"},
{"%I", "03"},
{"%M", "04"},
{"%S", "05"},
{"%p", "PM"},
{"%z", "-0700"},
{"%Z", "MST"},
{"%N", "000000000"},
{"%f", "000000"},
{"%L", "000"},
{"%F", "2006-01-02"},
{"%T", "15:04:05"},
{"%R", "15:04"},
{"%D", "01/02/06"},
{"%r", "03:04:05 PM"},
}
// StrftimeToGo converts a strftime-like layout into a Go time layout.
func StrftimeToGo(format string) string {
var out strings.Builder
for i := 0; i < len(format); {
if format[i] != '%' {
out.WriteByte(format[i])
i++
continue
}
matched := false
for _, r := range strftimeToGoRules {
if strings.HasPrefix(format[i:], r.strf) {
out.WriteString(r.goLayout)
i += len(r.strf)
matched = true
break
}
}
if !matched {
// Можно заменить на ошибку, если хочешь strict mode.
out.WriteByte(format[i])
i++
}
}
return out.String()
}
// GoToStrftime converts a Go time layout into an approximate strftime-like layout.
func GoToStrftime(format string) string {
format = strings.ReplaceAll(format, "2006", "%Y")
format = strings.ReplaceAll(format, "06", "%y")
format = strings.ReplaceAll(format, "15", "%H")
format = strings.ReplaceAll(format, "03", "%I")
format = strings.ReplaceAll(format, "3", "%-I")
format = strings.ReplaceAll(format, "3", "%#I")
format = strings.ReplaceAll(format, "January", "%B")
format = strings.ReplaceAll(format, "Jan", "%b")
format = strings.ReplaceAll(format, "01", "%m")
format = strings.ReplaceAll(format, "1", "%-m")
format = strings.ReplaceAll(format, "02", "%d")
format = strings.ReplaceAll(format, "_2", "%e")
format = strings.ReplaceAll(format, "2", "%-d")
format = strings.ReplaceAll(format, "Monday", "%A")
format = strings.ReplaceAll(format, "Mon", "%a")
format = strings.ReplaceAll(format, "04", "%M")
format = strings.ReplaceAll(format, "05", "%S")
format = strings.ReplaceAll(format, ".999999999", "%#N")
format = strings.ReplaceAll(format, "000000000", "%N")
format = strings.ReplaceAll(format, ".999999", "%#f")
format = strings.ReplaceAll(format, "000000", "%f")
format = strings.ReplaceAll(format, ".999", "%#L")
format = strings.ReplaceAll(format, "000", "%L")
format = strings.ReplaceAll(format, "PM", "%p")
format = strings.ReplaceAll(format, "-0700", "%z")
format = strings.ReplaceAll(format, "MST", "%Z")
format = strings.ReplaceAll(format, "Z07:00", "%:z")
format = strings.ReplaceAll(format, "Z07:00:00", "%::z")
format = strings.ReplaceAll(format, "Z07", "%:::z")
return format
}
+1 -1
View File
@@ -1,4 +1,4 @@
package slog package sneklog
import ( import (
"runtime" "runtime"
+1 -1
View File
@@ -1,4 +1,4 @@
package slog package sneklog
// Map applies f to each element of s and returns the resulting slice. // Map applies f to each element of s and returns the resulting slice.
func Map[T, R any](s []T, f func(T) R) []R { func Map[T, R any](s []T, f func(T) R) []R {
+94 -27
View File
@@ -1,6 +1,7 @@
package slog package sneklog
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -17,13 +18,40 @@ type LoggerWriter interface {
Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error
} }
// PrepareMessages normalizes message parts and extracts a trailing newline marker.
func PrepareMessages(messages ...any) (bool, []any) {
msg := Map(messages, func(el any) string { return fmt.Sprint(el) })
newline := false
if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") {
newline = true
msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n")
if msg[len(msg)-1] == "" {
msg = msg[:len(msg)-1]
}
}
return newline, Map(msg, func(el string) any { return any(el) })
}
// LoggerTextWriter writes human-readable log records to an io.Writer. // LoggerTextWriter writes human-readable log records to an io.Writer.
type LoggerTextWriter struct { type LoggerTextWriter struct {
LoggerWriter LoggerWriter
writer io.Writer writer io.Writer
closer io.Closer closer io.Closer
printTraceback bool formatter *Formatter
printTime bool }
// SetFormatter replaces the formatter used by the text writer.
func (w *LoggerTextWriter) SetFormatter(formatter *Formatter) *LoggerTextWriter {
w.formatter = formatter
return w
}
// Formatter returns the effective formatter for the text writer.
func (w *LoggerTextWriter) Formatter() *Formatter {
if w.formatter == nil {
return DefaultTextFormatter
}
return w.formatter
} }
// Write forwards raw bytes to the underlying writer. // Write forwards raw bytes to the underlying writer.
@@ -32,10 +60,26 @@ func (w *LoggerTextWriter) Write(p []byte) (n int, err error) {
} }
// Print formats the provided record as text and writes it to the underlying writer. // Print formats the provided record as text and writes it to the underlying writer.
func (w *LoggerTextWriter) Print(level LogLevel, prefix string, _ []*MethodTraceback, messages ...any) error { func (w *LoggerTextWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error {
s := BuildString(level, prefix, w.printTime, w.printTraceback, messages...) newline, messages := PrepareMessages(messages...)
f := w.Formatter()
s := f.FormatMessage(level, prefix, traceback, messages...)
if f.ColorOutput && (!f.ColorOnlyStdout || w.writer == os.Stdout || w.writer == os.Stderr) {
s = f.ColorizeString(s, level)
}
_, err := w.Write([]byte(s)) _, err := w.Write([]byte(s))
if err != nil {
return err return err
}
if newline {
_, err = w.Write([]byte("\n"))
if err != nil {
return err
}
}
return nil
} }
// Close closes the owned writer, if any. // Close closes the owned writer, if any.
@@ -52,14 +96,31 @@ type LoggerJsonWriter struct {
writer io.Writer writer io.Writer
closer io.Closer closer io.Closer
pretty bool pretty bool
formatter *Formatter
}
// Formatter returns the effective formatter for the JSON writer.
func (w *LoggerJsonWriter) Formatter() *Formatter {
if w.formatter == nil {
return DefaultJsonFormatter
}
return w.formatter
}
// SetFormatter replaces the formatter used by the JSON writer.
func (w *LoggerJsonWriter) SetFormatter(f *Formatter) *LoggerJsonWriter {
w.formatter = f
return w
} }
// LoggerJsonMessage is the JSON payload emitted by LoggerJsonWriter. // LoggerJsonMessage is the JSON payload emitted by LoggerJsonWriter.
type LoggerJsonMessage struct { type LoggerJsonMessage struct {
Time time.Time `json:"time"` Time string `json:"time"`
Level string `json:"level"` Level string `json:"level"`
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
Message string `json:"message"` Message string `json:"message"`
Traceback []*MethodTraceback `json:"traceback"` Traceback []*MethodTraceback `json:"traceback"`
} }
@@ -70,36 +131,42 @@ func (w *LoggerJsonWriter) Write(data []byte) (int, error) {
// Print encodes the provided record as JSON and writes it to the underlying writer. // Print encodes the provided record as JSON and writes it to the underlying writer.
func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error { func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*MethodTraceback, messages ...any) error {
msg := Map(messages, func(el any) string { return fmt.Sprint(el) }) newline, messages := PrepareMessages(messages...)
newline := false f := w.Formatter()
if len(msg) > 0 && strings.HasSuffix(msg[len(msg)-1], "\n") { s := f.FormatMessage(level, prefix, traceback, messages...)
newline = true if f.ColorOutput && (!f.ColorOnlyStdout || w.writer == os.Stdout || w.writer == os.Stderr) {
msg[len(msg)-1] = strings.TrimSuffix(msg[len(msg)-1], "\n") s = f.ColorizeString(s, level)
} }
m := LoggerJsonMessage{ m := LoggerJsonMessage{
Time: time.Now(), Time: f.FormatTime(time.Now()),
Level: level.GetName(), Level: level.GetName(),
Prefix: prefix, Prefix: prefix,
Message: strings.TrimSpace(strings.Join(msg, " ")), Message: s,
Traceback: traceback, Traceback: traceback,
} }
var data []byte
var err error buf := bytes.NewBuffer(nil)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(f.JSONEscapeHTML)
if w.pretty { if w.pretty {
data, err = json.MarshalIndent(m, "", " ") enc.SetIndent("", " ")
} else {
data, err = json.Marshal(m)
} }
err := enc.Encode(m)
if err != nil { if err != nil {
return err return err
} }
if newline { data := buf.Bytes()
data = append(data, '\n') if !newline {
data = bytes.TrimSuffix(data, []byte("\n"))
} }
_, err = w.Write(data) _, err = w.writer.Write(data)
if err != nil {
return err return err
}
return nil
} }
// Close closes the owned writer, if any. // Close closes the owned writer, if any.
@@ -112,16 +179,16 @@ func (w *LoggerJsonWriter) Close() error {
// CreateTextWriter wraps an external writer for text output without taking // CreateTextWriter wraps an external writer for text output without taking
// ownership of it. // ownership of it.
func CreateTextWriter(w io.Writer, printTraceback, printTime bool) *LoggerTextWriter { func CreateTextWriter(w io.Writer) *LoggerTextWriter {
writer := &LoggerTextWriter{ writer := &LoggerTextWriter{
writer: w, printTraceback: printTraceback, printTime: printTime, writer: w,
} }
return writer return writer
} }
// CreateTextFileWriter creates a text writer for path, creating parent // CreateTextFileWriter creates a text writer for path, creating parent
// directories as needed. The returned writer owns the opened file. // directories as needed. The returned writer owns the opened file.
func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerTextWriter, error) { func CreateTextFileWriter(path string) (*LoggerTextWriter, error) {
err := os.MkdirAll(filepath.Dir(path), os.ModePerm) err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -130,15 +197,15 @@ func CreateTextFileWriter(path string, printTraceback, printTime bool) (*LoggerT
if err != nil { if err != nil {
return nil, err return nil, err
} }
writer := CreateTextWriter(file, printTraceback, printTime) writer := CreateTextWriter(file)
writer.closer = file writer.closer = file
return writer, nil return writer, nil
} }
// CreateTextStdoutWriter creates a text writer for os.Stdout without taking // CreateTextStdoutWriter creates a text writer for os.Stdout without taking
// ownership of stdout. // ownership of stdout.
func CreateTextStdoutWriter(printTraceback, printTime bool) *LoggerTextWriter { func CreateTextStdoutWriter() *LoggerTextWriter {
writer := CreateTextWriter(os.Stdout, printTraceback, printTime) writer := CreateTextWriter(os.Stdout)
writer.closer = nil writer.closer = nil
return writer return writer
} }