diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0bd729..568bca1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,6 @@ +## +## Build the main branch +## name: build on: push: @@ -9,36 +12,52 @@ jobs: build: runs-on: ubuntu-latest - steps: + strategy: + matrix: + module: ["."] + steps: - uses: actions/setup-go@v5 with: go-version: "1.21" - uses: actions/checkout@v4 - + - name: go build + working-directory: ${{ matrix.module }} run: | go build ./... - name: go test + working-directory: ${{ matrix.module }} run: | - go test -v -coverprofile=profile.cov $(go list ./... | grep -v /examples/) + go test -coverprofile=profile.cov $(go list ./... | grep -v /examples/) + env: + ## GOPATH required to build serverless app inside unittest + GOPATH: /home/runner/work/${{ github.event.repository.name }}/go - uses: shogo82148/actions-goveralls@v1 continue-on-error: true with: + working-directory: ${{ matrix.module }} path-to-profile: profile.cov + flag-name: ${{ matrix.module }} + parallel: true - - uses: reecetech/version-increment@2023.10.2 - id: version - with: - scheme: semver - increment: patch - - - name: publish + - name: release + working-directory: ${{ matrix.module }} run: | git config user.name "GitHub Actions" git config user.email "github-actions@users.noreply.github.com" - git tag ${{ steps.version.outputs.v-version }} - git push origin -u ${{ steps.version.outputs.v-version }} \ No newline at end of file + for mod in `grep -roh "const Version = \".*" * | grep -Eoh "([[:alnum:]]*/*){1,}v[0-9]*\.[0-9]*\.[0-9]*"` + do + git tag $mod 2> /dev/null && git push origin -u $mod 2> /dev/null && echo "[+] $mod" || echo "[ ] $mod" + done + + finish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: shogo82148/actions-goveralls@v1 + with: + parallel-finished: true diff --git a/.github/workflows/check-code.yml b/.github/workflows/check-code.yml deleted file mode 100644 index 0ba66dc..0000000 --- a/.github/workflows/check-code.yml +++ /dev/null @@ -1,25 +0,0 @@ -## -## Quality checks -## -name: check -on: - pull_request: - types: - - opened - - synchronize - -jobs: - - code: - runs-on: ubuntu-latest - steps: - - - uses: actions/setup-go@v5 - with: - go-version: "1.21" - - - uses: actions/checkout@v4 - - - uses: dominikh/staticcheck-action@v1.3.1 - with: - install-go: false diff --git a/.github/workflows/check-test.yml b/.github/workflows/check-test.yml deleted file mode 100644 index 57e8f70..0000000 --- a/.github/workflows/check-test.yml +++ /dev/null @@ -1,34 +0,0 @@ -## -## Unit Tests & Coverage -## -name: test -on: - pull_request: - types: - - opened - - synchronize - -jobs: - - unit: - runs-on: ubuntu-latest - steps: - - - uses: actions/setup-go@v5 - with: - go-version: "1.21" - - - uses: actions/checkout@v4 - - - name: go build - run: | - go build ./... - - - name: go test - run: | - go test -v -coverprofile=profile.cov $(go list ./... | grep -v /examples/) - - - uses: shogo82148/actions-goveralls@v1 - continue-on-error: true - with: - path-to-profile: profile.cov diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5b88a22 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,60 @@ +## +## Unit Tests & Coverage +## +name: test +on: + pull_request: + types: + - opened + - synchronize + +jobs: + + unit: + runs-on: ubuntu-latest + strategy: + matrix: + module: ["."] + + steps: + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - uses: actions/checkout@v4 + + - name: go build + working-directory: ${{ matrix.module }} + run: | + go build ./... + + - name: go test + working-directory: ${{ matrix.module }} + run: | + go test -coverprofile=profile.cov $(go list ./... | grep -v /examples/) + env: + ## GOPATH required to build serverless app inside unittest + GOPATH: /home/runner/work/${{ github.event.repository.name }}/go + + - uses: shogo82148/actions-goveralls@v1 + continue-on-error: true + with: + working-directory: ${{ matrix.module }} + path-to-profile: profile.cov + flag-name: ${{ matrix.module }} + parallel: true + + - uses: dominikh/staticcheck-action@v1.3.1 + with: + install-go: false + working-directory: ${{ matrix.module }} + + finish: + needs: unit + runs-on: ubuntu-latest + steps: + - uses: shogo82148/actions-goveralls@v1 + with: + parallel-finished: true + diff --git a/README.md b/README.md index 2088964..be32683 100644 --- a/README.md +++ b/README.md @@ -20,37 +20,47 @@ --- -logger is a configuration for Golang [slog](https://pkg.go.dev/log/slog) developed for easy to use within serverless applications (e.g. logging to AWS CloudWatch). +`logger` is an opinionated configuration for Golang [slog](https://pkg.go.dev/log/slog) developed for easy to use within serverless applications, such as logging to AWS CloudWatch. ## Inspiration -The library outputs log messages as JSON object. The configuration enforces output filename and line of the log statement to facilitate further analysis. +The library enables 0-configuration for [slog](https://pkg.go.dev/log/slog) enabling two logging formats: +* Log messages as JSON object compatible with CloudWatch for production. +* Log messages as colored text and JSON for development ``` 2023-10-26 19:12:44.709 +0300 EEST: { "level": "INFO", "source": { - "function": "main.example", - "file": "example.go", + "function": "main.main", + "file": "gthb.fgfs.lggr.exmp/main.go", "line": 143 }, - "msg": "some output", + "msg": "informative status about system.", "key": "val", ... } ``` -The configuration inherits best practices of telecom application, it enhances existing `Debug`, `Info`, `Warn` and `Error` levels with 3 additional, making fine grained logging with 7 levels: +``` +[14:06:54.030] INF informative status about system. { + "key": "val", + "source": { + "file": "gthb.fgfs.lggr.exmp/main.go", + "function": "main.main", + "line": 26 + } +} +``` -1. `EMERGENCY`, `EMR`: system is unusable, panic execution of current routine or application, it is not possible to gracefully terminate it. -2. `CRITICAL`, `CRT`: system is failed, response actions must be taken immediately, the application is not able to execute correctly but still able to gracefully exit. -3. `ERROR`, `ERR`: system is failed, unable to recover from error. The failure do not have global catastrophic impacts but local functionality is impaired, incorrect result is returned. -4. `WARN`, `WRN`: system is failed, unable to recover, degraded functionality. The failure is ignored and application still capable to deliver incomplete but correct results. -5. `NOTICE`, `NTC`: system is failed, error is recovered, no impact. -6. `INFO`, `INF`: output informative status about system. -7. `DEBUG`, `DEB`: output debug status about system. +Additionally, it provides several enhancements: +* 7-level logging semantics for more precise error handling. +* File name shortening for cleaner and more readable logs. +* Module-based log level configuration for flexible logging control. +* Configuration via environment variables for easy customization. +* Built-in metrics for improved observability. ## Getting started @@ -59,82 +69,125 @@ The latest version of the configuration is available at `main` branch of this re Import configuration and start logging using `slog` api. The default config is optimized for logging within Serverless application. +```bash +go get -u github.com/fogfish/logger/v3 +``` + +- [Inspiration](#inspiration) +- [Getting started](#getting-started) + - [Quick Start](#quick-start) + - [Extended Logging Levels](#extended-logging-levels) + - [Configuration](#configuration) + - [Module-Based Log Level Configuration](#module-based-log-level-configuration) + - [AWS CloudWatch](#aws-cloudwatch) + - [Observability metrics](#observability-metrics) +- [How To Contribute](#how-to-contribute) + - [commit message](#commit-message) + - [bugs](#bugs) +- [License](#license) + + +### Quick Start + ```go +package main + import ( - "log/slog" + "context" + "log/slog" - _ "github.com/fogfish/logger/v3" + log "github.com/fogfish/logger/v3" ) -// 2023-10-26 19:12:44.709 +0300 EEST: -// { -// "level": "INFO", -// "source": { -// "function": "main.example", -// "file": "example.go", -// "line": 143 -// }, -// "msg": "some output", -// "key": "val", -// ... -// } -slog.Info("some message", "key", "val") +func main() { + slog.SetDefault(log.New()) + + slog.Info("informative status about system.") + slog.Warn("system is failed, unable to recover, degraded functionality.") + slog.Error("system is failed, unable to recover from error.") +} ``` -Use custom log levels if application requires more log levels +### Extended Logging Levels + +The `logger` library supplies configuration follows best practices from telecom applications, enhancing the standard `Debug`, `Info`, `Warn`, and `Error` levels with three additional levels. This provides fine-grained control over logging, resulting in seven distinct levels for correct error handling: +1. `EMERGENCY` (`EMR`) – The system is unusable. A panic occurs, and it is impossible to gracefully terminate the application. +2. `CRITICAL` (`CRT`) – The system has failed and requires immediate action. The application cannot function correctly but can still exit gracefully. +3. `ERROR` (`ERR`) – A failure has occurred, and recovery is not possible. While the issue does not have catastrophic global effects, local functionality is impaired, leading to incorrect results. +4. `WARN` (`WRN`) – A failure has occurred, and recovery is not possible. However, the system continues operating in a degraded state, delivering incomplete but correct results. +5. `NOTICE` (`NTC`) – A failure occurred but was successfully recovered, with no lasting impact on the system. +6. `INFO` (`INF`) – Provides informational updates on the system’s status. +7. `DEBUG` (`DEB`) – Outputs detailed debugging information for troubleshooting. + +This structured logging approach ensures clear categorization of system states, making it easier to detect, react to, and diagnose issues in complex applications. + +The faster way apply these levels is raw `slog.Log` function and standartized constants: ```go import ( - "log/slog" - log "github.com/fogfish/logger/v3" ) -slog.Log(context.Background(), log.EMERGENCY, "system emergency") +slog.Log(context.Background(), log.DEBUG, "...") +slog.Log(context.Background(), log.INFO, "...") +slog.Log(context.Background(), log.NOTICE, "...") +slog.Log(context.Background(), log.WARN, "...") +slog.Log(context.Background(), log.ERROR, "...") +slog.Log(context.Background(), log.CRITICAL, "...") +slog.Log(context.Background(), log.EMERGENCY, "...") +``` + +Alternatively, module `xlog` provides variants of these functions: + +```go +import ( + "github.com/fogfish/logger/x/xlog" +) + +xlog.Notice("...") +xlog.Warn("...", err) +xlog.Error("...", err) +xlog.Critical("...", err) +xlog.Emergency("...", err) ``` ### Configuration -The default configuration is AWS CloudWatch friendly. It applies INFO level logging, disables timestamps and messages are emitted to standard error (`os.Stderr`). Use `logger.New` to create custom logger config. +The typical configuration is following: ```go import ( "log/slog" - log "github.com/fogfish/logger/v3" ) -slog.SetDefault( - log.New( - log.WithWriter(), - log.WithLogLevel(), - log.WithLogLevelFromEnv(), - log.WithLogLevel7(), - log.WithLogLevelShorten(), - log.WithLogLevelForMod(), - log.WithLogLevelForModFromEnv(), - log.WithoutTimestamp(), - log.WithSourceFileName(), - log.WithSourceShorten(), - log.WithSource(), - ), -) +slog.SetDefault(log.New()) ``` -#### Config Log Level from Env +The default configuration works out-of-the-box, automatically adapting to the runtime environment. Adjust it Using functional option pattern, see all configuration options and presets [here](./options.go). -Use environment variable `CONFIG_LOG_LEVEL` to change log level of the application at runtime +The default log level is `INFO` and log messages are emitted to standard error (`os.Stderr`). Use environment variable `CONFIG_LOG_LEVEL` to change log level of the application at runtime: ```bash export CONFIG_LOGGER_LEVEL=WARN ``` -### Enable DEBUG for single module +Note: the environemnt configuration is case sensitive, all caps is required. + + +### Module-Based Log Level Configuration + +The logger allows you to define log levels for different modules with flexible granularity. Log levels can be set explicitly using configuration options or environment variables. + +The logger uses prefix matching to determine the appropriate log level based on the source code path: -The logger allows to define a log level per module. It either explicitly defined via config option or environment variables. The logger uses each string as prefix to match it against source code path: -* `github.com/fogfish/logger/logger.go` defines log level for single file -* `github.com/fogfish/logger` defines log level for entire module -* `github.com/fogfish` defines log level for all modules by user +**Per File**: A log level defined for a specific file (e.g., `github.com/fogfish/logger/logger.go`) applies only to that file. + +**Per Module**: A log level set for a module (e.g., `github.com/fogfish/logger`) applies to all files within that module. + +**Per Namespace**: A log level defined at a higher level (e.g., `github.com/fogfish`) applies to all modules under that namespace. + +You either do explicit configuration using the config option ```go import ( @@ -146,20 +199,21 @@ import ( slog.SetDefault( log.New( log.WithLogLevelForMod(map[string]slog.Level{ - "github.com/fogfish/logger": log.INFO, + "github.com/fogfish/logger": log.INFO, "github.com/you/application": log.DEBUG, }), ), ) ``` -Use environment variable `CONFIG_LOG_LEVEL_{LEVEL_NAME}` +Or, using environment variable `CONFIG_LOG_LEVEL_{LEVEL_NAME}` ```bash export CONFIG_LOG_LEVEL_DEBUG=github.com/you/application:github.com/ export CONFIG_LOG_LEVEL_INFO=github.com/fogfish/logger ``` + ### AWS CloudWatch The logger output events in the format compatible with AWS CloudWatch: each log message corresponds to single CloudWatch event. Therefore, it simplify logging in AWS Lambda functions. Use the logger together with CloudWatch Insight (e.g. utility [awslog](https://github.com/fogfish/awslog)) for the deep analysis. For example, search events with logs insight queries: @@ -171,6 +225,37 @@ fields @timestamp, @message | limit 20 ``` +### Observability metrics + +Logging **duration** of the function + +```go +func do() { + defer slog.Info("done something", slog.Any("duration", xlog.SinceNow())) + // ... +} +``` + +Logging **execution rate** of the code block. + +```go +func do() { + ops := xlog.PerSecondNow() + defer slog.Info("done something", slog.Any("op/sec", ops)) + // ops.Acc++ +} +``` + +Logging **demand** of the code block + +```go +func do() { + ops := xlog.MillisecondOpNow() + defer slog.Info("done something", slog.Any("op/sec", ops)) + // ops.Acc++ +} +``` + ## How To Contribute The library is [MIT](LICENSE) licensed and accepts contributions via GitHub pull requests: diff --git a/attributes.go b/attributes.go new file mode 100644 index 0000000..53ece47 --- /dev/null +++ b/attributes.go @@ -0,0 +1,175 @@ +// +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/logger +// + +package logger + +import ( + "log/slog" + "path/filepath" + "strings" + "time" +) + +// Log attributes, forammting combinator +type Attributes []func(groups []string, a slog.Attr) slog.Attr + +func (attrs Attributes) handle(groups []string, a slog.Attr) slog.Attr { + for _, f := range attrs { + a = f(groups, a) + } + return a +} + +//------------------------------------------------------------------------------ + +// Logs timestamp using the time format string +func attrLogTimeFormat(format string) func([]string, slog.Attr) slog.Attr { + return func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + t := a.Value.Any().(time.Time) + return slog.String(slog.TimeKey, t.Format(format)) + } + + return a + } +} + +// Skip timestamp from log +func attrNoTimestamp(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey && len(groups) == 0 { + return slog.Attr{} + } + + return a +} + +//------------------------------------------------------------------------------ + +// Logs longs level names using 7-levels and colors +func attrLogLevel7(color bool) func([]string, slog.Attr) slog.Attr { + return func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + lvl := a.Value.Any().(slog.Level) + + name, has := levelLongName[lvl] + if !has { + return a + } + + if !color { + return slog.String(slog.LevelKey, name) + } + + color := levelColorForName[lvl] + return slog.String(slog.LevelKey, color+name+colorReset) + } + + return a + } +} + +// Logs short level names (3 chars) using 7-levels and colors +func attrLogLevel7Shorten(color bool) func([]string, slog.Attr) slog.Attr { + return func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.LevelKey { + lvl, ok := a.Value.Any().(slog.Level) + if !ok { + return a + } + + name, has := levelShortName[lvl] + if !has { + return a + } + + if !color { + return slog.String(slog.LevelKey, name) + } + + color := levelColorForName[lvl] + return slog.String(slog.LevelKey, color+name+colorReset) + } + + return a + } +} + +//------------------------------------------------------------------------------ + +// Logs only name of the file instead of full path +func attrSourceFileName(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + source, _ := a.Value.Any().(*slog.Source) + if source != nil { + source.File = filepath.Base(source.File) + } + } + + return a +} + +// Logs full path to the source file, applying shortening heuristic +func attrSourceShorten(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + source, _ := a.Value.Any().(*slog.Source) + if source != nil { + parts := strings.Split(source.File, "go/src/") + path := parts[0] + if len(parts) > 1 { + path = parts[1] + } + source.File = shorten(path) + source.Function = shorten(source.Function) + } + } + + return a +} + +func shorten(path string) string { + seq := strings.Split(path, string(filepath.Separator)) + if len(seq) == 1 { + return path + } + + for i := 0; i < len(seq)-1; i++ { + seq[i] = shortenSegment(seq[i]) + } + return strings.Join(seq[0:len(seq)-1], ".") + "/" + seq[len(seq)-1] +} + +// shortenSegment heuristically shortens a path segment +func shortenSegment(segment string) string { + length := len(segment) + switch { + case length <= 3: + return segment // Keep short names unchanged + case length <= 6: + return removeVowels(segment) // Remove vowels from medium-length names + default: + short := removeVowels(segment) + if len(short) > 4 { + return short[:4] + } + return short + } +} + +func removeVowels(segment string) string { + vowels := "aeiouAEIOU" + keep := []rune{} + for i, r := range segment { + if i == 0 || !strings.ContainsRune(vowels, r) { + keep = append(keep, r) + } + } + if len(keep) < 2 { // Ensure at least 2 characters remain + return segment + } + return string(keep) +} diff --git a/attributes_test.go b/attributes_test.go new file mode 100644 index 0000000..dfd8b7d --- /dev/null +++ b/attributes_test.go @@ -0,0 +1,133 @@ +// +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/logger +// + +package logger + +import ( + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestAttrLogTimeFormat(t *testing.T) { + format := "2006-01-02 15:04:05" + attr := attrLogTimeFormat(format) + timestamp := time.Now() + a := slog.Attr{Key: slog.TimeKey, Value: slog.AnyValue(timestamp)} + + result := attr(nil, a) + expected := timestamp.Format(format) + + if result.Value.String() != expected { + t.Errorf("expected %s, got %s", expected, result.Value.String()) + } +} + +func TestAttrNoTimestamp(t *testing.T) { + attr := attrNoTimestamp + timestamp := time.Now() + a := slog.Attr{Key: slog.TimeKey, Value: slog.AnyValue(timestamp)} + + result := attr(nil, a) + if result.Key != "" { + t.Errorf("expected empty key, got %s", result.Key) + } + + result = attr([]string{"group"}, a) + if result.Key != slog.TimeKey { + t.Errorf("expected %s, got %s", slog.TimeKey, result.Key) + } +} + +func TestAttrLogLevel7(t *testing.T) { + attr := attrLogLevel7(false) + level := slog.LevelInfo + a := slog.Attr{Key: slog.LevelKey, Value: slog.AnyValue(level)} + + result := attr(nil, a) + expected := levelLongName[level] + + if result.Value.String() != expected { + t.Errorf("expected %s, got %s", expected, result.Value.String()) + } +} + +func TestAttrLogLevel7Shorten(t *testing.T) { + attr := attrLogLevel7Shorten(false) + level := slog.LevelInfo + a := slog.Attr{Key: slog.LevelKey, Value: slog.AnyValue(level)} + + result := attr(nil, a) + expected := levelShortName[level] + + if result.Value.String() != expected { + t.Errorf("expected %s, got %s", expected, result.Value.String()) + } +} + +func TestAttrSourceFileName(t *testing.T) { + attr := attrSourceFileName + source := &slog.Source{File: "/path/to/file.go"} + a := slog.Attr{Key: slog.SourceKey, Value: slog.AnyValue(source)} + + result := attr(nil, a) + expected := filepath.Base(source.File) + + if result.Value.Any().(*slog.Source).File != expected { + t.Errorf("expected %s, got %s", expected, result.Value.Any().(*slog.Source).File) + } +} + +func TestAttrSourceShorten(t *testing.T) { + attr := attrSourceShorten + source := &slog.Source{File: "/path/to/go/src/github.com/fogfish/logger/attributes.go", Function: "github.com/fogfish/logger.TestFunc"} + a := slog.Attr{Key: slog.SourceKey, Value: slog.AnyValue(source)} + + result := attr(nil, a) + expectedFile := "gthb.fgfs.lggr/attributes.go" + expectedFunc := "gthb.fgfs/logger.TestFunc" + + if result.Value.Any().(*slog.Source).File != expectedFile { + t.Errorf("expected %s, got %s", expectedFile, result.Value.Any().(*slog.Source).File) + } + + if result.Value.Any().(*slog.Source).Function != expectedFunc { + t.Errorf("expected %s, got %s", expectedFunc, result.Value.Any().(*slog.Source).Function) + } +} + +func TestShorten(t *testing.T) { + path := "github.com/fogfish/logger/attributes.go" + expected := "gthb.fgfs.lggr/attributes.go" + + result := shorten(path) + if result != expected { + t.Errorf("expected %s, got %s", expected, result) + } +} + +func TestShortenSegment(t *testing.T) { + segment := "logger" + expected := "lggr" + + result := shortenSegment(segment) + if result != expected { + t.Errorf("expected %s, got %s", expected, result) + } +} + +func TestRemoveVowels(t *testing.T) { + segment := "logger" + expected := "lggr" + + result := removeVowels(segment) + if result != expected { + t.Errorf("expected %s, got %s", expected, result) + } +} diff --git a/examples/main.go b/examples/main.go deleted file mode 100644 index 64dd9eb..0000000 --- a/examples/main.go +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (C) 2021 Dmitry Kolesnikov -// -// This file may be modified and distributed under the terms -// of the MIT license. See the LICENSE file for details. -// https://github.com/fogfish/logger -// - -package main - -import ( - "context" - "log/slog" - - log "github.com/fogfish/logger/v3" -) - -func main() { - slog.Debug("debug message", "key", "val") - slog.Info("info message", "key", "val") - - slog.Log(context.Background(), log.EMERGENCY, "system emergency") -} diff --git a/handler.go b/handler.go index c6cf10f..3710c06 100644 --- a/handler.go +++ b/handler.go @@ -1,5 +1,5 @@ // -// Copyright (C) 2021 Dmitry Kolesnikov +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. @@ -9,22 +9,55 @@ package logger import ( + "bytes" "context" + "encoding/json" + "fmt" + "io" "log/slog" "runtime" "strings" + "sync" "github.com/fogfish/logger/v3/internal/trie" ) -type handler struct { +// JSON logger handler +func NewJSONHandler(opts ...Option) slog.Handler { + config := defaultOpts(CloudWatch...) + for _, opt := range opts { + opt(config) + } + + h := slog.NewJSONHandler(config.writer, + &slog.HandlerOptions{ + AddSource: config.addSource, + Level: config.level, + ReplaceAttr: config.attributes.handle, + }, + ) + + if config.trie == nil { + return h + } + + return &modTrieHandler{ + Handler: h, + trie: config.trie, + } +} + +//------------------------------------------------------------------------------ + +// The handler perform module-based logging +type modTrieHandler struct { slog.Handler trie *trie.Node } -func (h *handler) Enabled(context.Context, slog.Level) bool { return true } +func (h *modTrieHandler) Enabled(context.Context, slog.Level) bool { return true } -func (h *handler) Handle(ctx context.Context, r slog.Record) error { +func (h *modTrieHandler) Handle(ctx context.Context, r slog.Record) error { if r.PC == 0 { return h.Handler.Handle(ctx, r) } @@ -46,3 +79,107 @@ func (h *handler) Handle(ctx context.Context, r slog.Record) error { return nil } + +//------------------------------------------------------------------------------ + +type stdioHandler struct { + w io.Writer + h slog.Handler + b *bytes.Buffer + m sync.Mutex +} + +// Standard I/O handler +func NewStdioHandler(opts ...Option) slog.Handler { + config := defaultOpts(Console...) + for _, opt := range opts { + opt(config) + } + + b := &bytes.Buffer{} + h := slog.NewJSONHandler(b, + &slog.HandlerOptions{ + AddSource: config.addSource, + Level: config.level, + ReplaceAttr: config.attributes.handle, + }, + ) + + if config.trie == nil { + return &stdioHandler{w: config.writer, b: b, h: h} + } + + return &stdioHandler{w: config.writer, b: b, + h: &modTrieHandler{ + Handler: h, + trie: config.trie, + }, + } +} + +func (h *stdioHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.h.Enabled(ctx, level) +} + +func (h *stdioHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &stdioHandler{h: h.h.WithAttrs(attrs), b: h.b} +} + +func (h *stdioHandler) WithGroup(name string) slog.Handler { + return &stdioHandler{h: h.h.WithGroup(name), b: h.b} +} + +func (h *stdioHandler) Handle(ctx context.Context, r slog.Record) error { + attrs, err := h.computeAttrs(ctx, r) + if err != nil { + return err + } + + time := attrs["time"] + + level := attrs["level"] + cl := levelColorForText[r.Level] + msg := cl + r.Message + colorReset + + delete(attrs, "level") + delete(attrs, "time") + delete(attrs, "msg") + + if len(attrs) == 0 { + fmt.Fprintln(h.w, time, level, msg) + return nil + } + + bytes, err := json.MarshalIndent(attrs, "", " ") + if err != nil { + return err + } + + ca := levelColorForAttr[r.Level] + obj := ca + string(bytes) + colorReset + + fmt.Fprintln(h.w, time, level, msg, obj) + return nil +} + +func (h *stdioHandler) computeAttrs( + ctx context.Context, + r slog.Record, +) (map[string]any, error) { + h.m.Lock() + defer func() { + h.b.Reset() + h.m.Unlock() + }() + + if err := h.h.Handle(ctx, r); err != nil { + return nil, err + } + + var attrs map[string]any + err := json.Unmarshal(h.b.Bytes(), &attrs) + if err != nil { + return nil, err + } + return attrs, nil +} diff --git a/logger.go b/logger.go index 0ec1f1f..fee7d83 100644 --- a/logger.go +++ b/logger.go @@ -1,5 +1,5 @@ // -// Copyright (C) 2021 Dmitry Kolesnikov +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. @@ -10,26 +10,26 @@ package logger import ( - "io" "log/slog" "os" - "path/filepath" - "strings" - - "github.com/fogfish/logger/v3/internal/trie" ) -func init() { - // Config Optimal for logging of serverless with AWS Cloud Watch - slog.SetDefault( - New( - WithLogLevelFromEnv(), - WithLogLevel7(), - WithSourceShorten(), - WithoutTimestamp(), - WithLogLevelForModFromEnv(), - ), - ) +// Create New Logger +func New(opts ...Option) *slog.Logger { + if _, has := os.LookupEnv("AWS_LAMBDA_FUNCTION_NAME"); has { + return slog.New(NewJSONHandler(opts...)) + } + + if preset, has := os.LookupEnv("CONFIG_LOG_PROFILE"); has { + switch preset { + case "CloudWatch": + return slog.New(NewJSONHandler(opts...)) + default: + return slog.New(NewStdioHandler(opts...)) + } + } + + return slog.New(NewStdioHandler(opts...)) } const ( @@ -37,40 +37,47 @@ const ( // system is unusable, panic execution of current routine/application, // it is notpossible to gracefully terminate it. EMERGENCY = slog.Level(100) + EMR = EMERGENCY // CRITICAL // system is failed, response actions must be taken immediately, // the application is not able to execute correctly but still // able to gracefully exit. CRITICAL = slog.Level(50) + CRT = CRITICAL // ERROR // system is failed, unable to recover from error. // The failure do not have global catastrophic impacts but // local functionality is impaired, incorrect result is returned. ERROR = slog.LevelError + ERR = ERROR // WARN // system is failed, unable to recover, degraded functionality. // The failure is ignored and application still capable to deliver // incomplete but correct results. WARN = slog.LevelWarn + WRN = WARN // NOTICE // system is failed, error is recovered, no impact NOTICE = slog.Level(2) + NTC = NOTICE // INFO // output informative status about system INFO = slog.LevelInfo + INF = INFO // DEBUG // output debug status about system DEBUG = slog.LevelDebug + DEB = DEBUG ) var ( - shortLevel = map[slog.Level]string{ + levelShortName = map[slog.Level]string{ EMERGENCY: "EMR", CRITICAL: "CRT", ERROR: "ERR", @@ -80,7 +87,7 @@ var ( DEBUG: "DEB", } - longLevel = map[slog.Level]string{ + levelLongName = map[slog.Level]string{ EMERGENCY: "EMERGENCY", CRITICAL: "CRITICAL", ERROR: "ERROR", @@ -90,6 +97,38 @@ var ( DEBUG: "DEBUG", } + colorReset = "\x1b[0m" + + levelColorForName = map[slog.Level]string{ + EMERGENCY: "\x1b[1;48;5;198m", // Red (intensive) + CRITICAL: "\x1b[48;5;160m", // Red (mid-intensive) + ERROR: "\x1b[1;31m", // Red + WARN: "\x1b[1;38;5;178m", // Orange + NOTICE: "\x1b[0;38;5;111m", // Blue + INFO: "\x1b[38;5;255m", // White + DEBUG: "\x1b[38;5;248m", // Grey + } + + levelColorForText = map[slog.Level]string{ + EMERGENCY: "\x1b[1;38;5;198m", // Red + CRITICAL: "\x1b[0;38;5;160m", // Red + ERROR: "\x1b[38;5;15m", // White + WARN: "\x1b[38;5;15m", // White + NOTICE: "\x1b[38;5;15m", // White + INFO: "\x1b[38;5;15m", // White + DEBUG: "\x1b[0m", // default + } + + levelColorForAttr = map[slog.Level]string{ + EMERGENCY: "\x1b[38;5;255m", // White + CRITICAL: "\x1b[38;5;255m", // White + ERROR: "\x1b[38;5;250m", // Grey + WARN: "\x1b[38;5;244m", // Drak Gray + NOTICE: "\x1b[38;5;244m", // Dark Gray + INFO: "\x1b[90m", // Darker Gray + DEBUG: "\x1b[90m", // Darker Gray + } + longNames = map[string]slog.Level{ "EMERGENCY": EMERGENCY, "CRITICAL": CRITICAL, @@ -100,249 +139,3 @@ var ( "DEBUG": DEBUG, } ) - -// combinator of attribute formatting -type Attributes []func(groups []string, a slog.Attr) slog.Attr - -func (attrs Attributes) handle(groups []string, a slog.Attr) slog.Attr { - for _, f := range attrs { - a = f(groups, a) - } - return a -} - -type opts struct { - writer io.Writer - level slog.Leveler - attributes Attributes - addSource bool - trie *trie.Node -} - -func defaultOpts() *opts { - return &opts{ - writer: os.Stdout, - level: INFO, - attributes: Attributes{}, - addSource: false, - } -} - -// Config options for slog -type Option func(*opts) - -// Config Log writer, default os.Stdout -func WithWriter(w io.Writer) Option { - return func(o *opts) { - o.writer = w - } -} - -// Config Log Level, default INFO -func WithLogLevel(level slog.Leveler) Option { - return func(o *opts) { - o.level = level - } -} - -// Config Log Level from env CONFIG_LOG_LEVEL, default INFO -func WithLogLevelFromEnv() Option { - return func(o *opts) { - level, defined := os.LookupEnv("CONFIG_LOG_LEVEL") - if !defined { - return - } - - if lvl, has := longNames[level]; has { - o.level = lvl - } - } -} - -// WithLevel7 enables from DEBUG to EMERGENCY levels -func WithLogLevel7() Option { - return func(o *opts) { - o.attributes = append(o.attributes, attrLogLevel7) - } -} - -func attrLogLevel7(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.LevelKey { - switch a.Value.Any() { - case EMERGENCY: - return slog.String(slog.LevelKey, longLevel[EMERGENCY]) - case CRITICAL: - return slog.String(slog.LevelKey, longLevel[CRITICAL]) - case NOTICE: - return slog.String(slog.LevelKey, longLevel[NOTICE]) - default: - return a - } - } - - return a -} - -// Config Log Level to be 3 letters only -func WithLogLevelShorten() Option { - return func(o *opts) { - o.attributes = append(o.attributes, attrLogLevelShorten) - } -} - -func attrLogLevelShorten(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.LevelKey { - lvl := a.Value.Any().(slog.Level) - if name, has := shortLevel[lvl]; has { - return slog.String(slog.LevelKey, name) - } - } - return a -} - -// Config Log Levels per module -func WithLogLevelForMod(mods map[string]slog.Level) Option { - return func(o *opts) { - o.trie = trie.New() - for mod, lvl := range mods { - o.trie.Append(mod, lvl) - } - } -} - -// Config Log Levels per module from env variables CONFIG_LOG_LEVEL_{NAME} -// -// CONFIG_LOG_LEVEL_DEBUG=github.com/fogfish/logger/*:github.com/your/app -func WithLogLevelForModFromEnv() Option { - return func(o *opts) { - root := trie.New() - for lvl, name := range longLevel { - for _, mod := range fromEnvMods("CONFIG_LOG_LEVEL_" + name) { - root.Append(mod, lvl) - } - } - - if len(root.Heir) != 0 { - o.trie = root - } - } -} - -func fromEnvMods(key string) []string { - value, defined := os.LookupEnv(key) - if !defined { - return nil - } - - return strings.Split(value, ":") -} - -// Exclude timestamp, required by CloudWatch -func WithoutTimestamp() Option { - return func(o *opts) { - o.attributes = append(o.attributes, attrNoTimestamp) - } -} - -func attrNoTimestamp(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey && len(groups) == 0 { - return slog.Attr{} - } - - return a -} - -// Logs file name of the source file -func WithSourceFileName() Option { - return func(o *opts) { - o.addSource = true - o.attributes = append(o.attributes, attrSourceFileName) - } -} - -func attrSourceFileName(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.SourceKey { - source, _ := a.Value.Any().(*slog.Source) - if source != nil { - source.File = filepath.Base(source.File) - } - } - - return a -} - -// Shorten Source file to letters only -func WithSourceShorten() Option { - return func(o *opts) { - o.addSource = true - o.attributes = append(o.attributes, attrSourceShorten) - } -} - -func attrSourceShorten(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.SourceKey { - source, _ := a.Value.Any().(*slog.Source) - if source != nil { - parts := strings.Split(source.File, "go/src/") - path := parts[0] - if len(parts) > 1 { - path = parts[1] - } - source.File = shorten(path) - source.Function = shorten(source.Function) - } - } - - return a -} - -func shorten(path string) string { - seq := strings.Split(path, string(filepath.Separator)) - if len(seq) == 1 { - return path - } - - for i := 0; i < len(seq)-1; i++ { - if len(seq[i]) > 0 { - seq[i] = strings.ToLower(seq[i][0:1]) - } - } - return strings.Join(seq[0:len(seq)-1], ".") + "/" + seq[len(seq)-1] -} - -// Enable logging of source file -func WithSource() Option { - return func(o *opts) { - o.addSource = true - } -} - -// Create New Logger -func New(opts ...Option) *slog.Logger { - return slog.New(NewJSONHandler(opts...)) -} - -// Create's new handler -func NewJSONHandler(opts ...Option) slog.Handler { - config := defaultOpts() - for _, opt := range opts { - opt(config) - } - - h := slog.NewJSONHandler(config.writer, - &slog.HandlerOptions{ - AddSource: config.addSource, - Level: config.level, - ReplaceAttr: config.attributes.handle, - }, - ) - - if config.trie == nil { - return h - } - - return &handler{ - Handler: h, - trie: config.trie, - } -} diff --git a/logger_test.go b/logger_test.go index 0d97513..e8aaf86 100644 --- a/logger_test.go +++ b/logger_test.go @@ -1,5 +1,5 @@ // -// Copyright (C) 2021 Dmitry Kolesnikov +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov // // This file may be modified and distributed under the terms // of the MIT license. See the LICENSE file for details. @@ -8,20 +8,123 @@ package logger -import "testing" - -func TestShorten(t *testing.T) { - for in, expect := range map[string]string{ - "afoo/bfoo/cfoo/def": "a.b.c/def", - "a/b/c/d": "a.b.c/d", - "bfoo/cfoo/def": "b.c/def", - "cfoo/def": "c/def", - "def": "def", - "main.main": "main.main", - "github.com/fogfish/logger/internal/codec.(*CodecRaw).Process": "g.f.l.i/codec.(*CodecRaw).Process", - } { - if shorten(in) != expect { - t.Errorf("%s not shorten to %s", in, expect) - } - } +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +func TestStdioLogger(t *testing.T) { + b := &bytes.Buffer{} + log := slog.New(NewStdioHandler(WithWriter(b), WithLogLevel(DEBUG))) + + t.Run("Debug", func(t *testing.T) { + defer b.Reset() + + log.Debug("test") + txt := b.String() + if !strings.Contains(txt, "DEB") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Info", func(t *testing.T) { + defer b.Reset() + + log.Info("test") + txt := b.String() + if !strings.Contains(txt, "INF") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Warn", func(t *testing.T) { + defer b.Reset() + + log.Warn("test") + txt := b.String() + if !strings.Contains(txt, "WRN") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Error", func(t *testing.T) { + defer b.Reset() + + log.Error("test") + txt := b.String() + if !strings.Contains(txt, "ERR") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) +} + +func TestJSONLogger(t *testing.T) { + b := &bytes.Buffer{} + log := slog.New(NewJSONHandler(WithWriter(b), WithLogLevel(DEBUG))) + + t.Run("Debug", func(t *testing.T) { + defer b.Reset() + + log.Debug("test") + txt := b.String() + if !strings.Contains(txt, "DEBUG") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Info", func(t *testing.T) { + defer b.Reset() + + log.Info("test") + txt := b.String() + if !strings.Contains(txt, "INFO") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Warn", func(t *testing.T) { + defer b.Reset() + + log.Warn("test") + txt := b.String() + if !strings.Contains(txt, "WARN") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) + + t.Run("Error", func(t *testing.T) { + defer b.Reset() + + log.Error("test") + txt := b.String() + if !strings.Contains(txt, "ERROR") || + !strings.Contains(txt, "test") || + !strings.Contains(txt, "source") || + !strings.Contains(txt, "lggr") { + t.Errorf("unexpected log line %s", txt) + } + }) } diff --git a/options.go b/options.go new file mode 100644 index 0000000..9acecfc --- /dev/null +++ b/options.go @@ -0,0 +1,221 @@ +// +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/logger +// + +package logger + +import ( + "io" + "log/slog" + "os" + "strings" + + "github.com/fogfish/logger/v3/internal/trie" +) + +var ( + // Preset for console logging + Console = []Option{ + func(o *opts) { o.attributes = append(o.attributes, attrLogLevel7Shorten(true)) }, + WithTimeFormat("[15:04:05.000]"), + WithLogLevel(INFO), + WithLogLevelFromEnv(), + WithSourceShorten(), + WithLogLevelForModFromEnv(), + } + + // Preset for CloudWatch logging + CloudWatch = []Option{ + WithLogLevelFromEnv(), + WithLogLevel7(), + WithSourceShorten(), + WithoutTimestamp(), + WithLogLevelForModFromEnv(), + } +) + +// The logger config option +type Option func(*opts) + +type opts struct { + writer io.Writer + level slog.Leveler + attributes Attributes + addSource bool + trie *trie.Node +} + +func defaultOpts(preset ...Option) *opts { + opt := &opts{ + writer: os.Stdout, + level: INFO, + attributes: Attributes{}, + addSource: false, + } + for _, f := range preset { + f(opt) + } + + return opt +} + +// Config Log writer, default os.Stdout +func WithWriter(w io.Writer) Option { + return func(o *opts) { + o.writer = w + } +} + +// Exclude timestamp, required by CloudWatch +func WithoutTimestamp() Option { + return func(o *opts) { + o.attributes = append(o.attributes, attrNoTimestamp) + } +} + +// Configure the time format +func WithTimeFormat(format string) Option { + return func(o *opts) { + o.attributes = append(o.attributes, attrLogTimeFormat(format)) + } +} + +// Config Log Level, default INFO +func WithLogLevel(level slog.Leveler) Option { + return func(o *opts) { + o.level = level + } +} + +// Config Log Level from env CONFIG_LOG_LEVEL, default INFO +// +// export CONFIG_LOG_LEVEL=DEBUG +func WithLogLevelFromEnv() Option { + return func(o *opts) { + level, defined := os.LookupEnv("CONFIG_LOG_LEVEL") + if !defined { + return + } + + if lvl, has := longNames[level]; has { + o.level = lvl + } + } +} + +// WithLevel7 enables from DEBUG to EMERGENCY levels +func WithLogLevel7() Option { + return func(o *opts) { + o.attributes = append(o.attributes, attrLogLevel7(false)) + } +} + +// Config Log Level to be 3 letters only from DEB to EMR +func WithLogLevelShorten() Option { + return func(o *opts) { + o.attributes = append(o.attributes, attrLogLevel7Shorten(false)) + } +} + +// The logger allows you to define log levels for different modules with +// flexible granularity. Log levels can be set explicitly using this configuration +// options. +// +// log.WithLogLevelForMod(map[string]slog.Level{ +// "github.com/fogfish/logger": log.INFO, +// "github.com/you/application": log.DEBUG, +// }) +// +// The logger uses prefix matching to determine the appropriate log level based +// on the source code path: +// +// * Per File: A log level defined for a specific file +// (e.g., github.com/fogfish/logger/logger.go) applies only to that file. +// +// * Per Module: A log level set for a module (e.g., github.com/fogfish/logger) +// applies to all files within that module. +// +// * Per User/Namespace: A log level defined at a higher level +// (e.g., github.com/fogfish) applies to all modules under that namespace. +func WithLogLevelForMod(mods map[string]slog.Level) Option { + return func(o *opts) { + o.trie = trie.New() + for mod, lvl := range mods { + o.trie.Append(mod, lvl) + } + } +} + +// The logger allows you to define log levels for different modules with +// flexible granularity. Log levels can be set explicitly using environment +// variables CONFIG_LOG_LEVEL_{NAME}: +// +// export CONFIG_LOG_LEVEL_DEBUG=github.com/fogfish/logger:github.com/your/app +// export CONFIG_LOG_LEVEL_INFO=github.com/fogfish +// +// * Per File: A log level defined for a specific file +// (e.g., github.com/fogfish/logger/logger.go) applies only to that file. +// +// * Per Module: A log level set for a module (e.g., github.com/fogfish/logger) +// applies to all files within that module. +// +// * Per User/Namespace: A log level defined at a higher level +// (e.g., github.com/fogfish) applies to all modules under that namespace. + +func WithLogLevelForModFromEnv() Option { + return func(o *opts) { + root := trie.New() + for lvl, name := range levelLongName { + for _, mod := range fromEnvMods("CONFIG_LOG_LEVEL_" + name) { + root.Append(mod, lvl) + } + } + + if len(root.Heir) != 0 { + o.trie = root + } + } +} + +func fromEnvMods(key string) []string { + value, defined := os.LookupEnv(key) + if !defined { + return nil + } + + return strings.Split(value, ":") +} + +// Logs file name of the source file only +func WithSourceFileName() Option { + return func(o *opts) { + o.addSource = true + o.attributes = append(o.attributes, attrSourceFileName) + } +} + +// Shorten Source file to few letters only +func WithSourceShorten() Option { + return func(o *opts) { + o.addSource = true + o.attributes = append(o.attributes, attrSourceShorten) + } +} + +// Enable default logging of source file +func WithSource() Option { + return func(o *opts) { + o.addSource = true + } +} + +// Disable logging of source file +func WithoutSource() Option { + return func(o *opts) { + o.addSource = false + } +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..88627ad --- /dev/null +++ b/version.go @@ -0,0 +1,11 @@ +// +// Copyright (C) 2021 - 2025 Dmitry Kolesnikov +// +// This file may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// https://github.com/fogfish/logger +// + +package logger + +const Version = "v3.2.0" diff --git a/xlog/types.go b/xlog/types.go deleted file mode 100644 index 5fd0979..0000000 --- a/xlog/types.go +++ /dev/null @@ -1,60 +0,0 @@ -// -// Copyright (C) 2021 - 2024 Dmitry Kolesnikov -// -// This file may be modified and distributed under the terms -// of the MIT license. See the LICENSE file for details. -// https://github.com/fogfish/logger -// - -package xlog - -import ( - "encoding/json" - "strconv" - "time" -) - -// Since logs duration passed since given time -type Since time.Time - -func (s Since) String() string { return time.Since(time.Time(s)).String() } -func (s Since) MarshalJSON() ([]byte, error) { return json.Marshal(s.String()) } - -// SinceNow return instance of [Since] type initialized with [time.Now] -func SinceNow() Since { return Since(time.Now()) } - -//------------------------------------------------------------------------------ - -// PerSecond logs rates per second since given time -type PerSecond struct { - T time.Time - Acc int -} - -func (r PerSecond) String() string { - v := float64(r.Acc) / time.Since(r.T).Seconds() - return strconv.FormatFloat(v, 'f', 4, 64) -} - -func (r PerSecond) MarshalJSON() ([]byte, error) { return json.Marshal(r.String()) } - -// PerSecondNow return instance of [SecondNow] type initialized with [time.Now] -func PerSecondNow() PerSecond { return PerSecond{T: time.Now(), Acc: 0} } - -//------------------------------------------------------------------------------ - -// MillisecondOp logs millisecond required for operation -type MillisecondOp struct { - T time.Time - Acc int -} - -func (r MillisecondOp) String() string { - v := time.Since(r.T).Milliseconds() / int64(r.Acc) - return strconv.Itoa(int(v)) -} - -func (r MillisecondOp) MarshalJSON() ([]byte, error) { return json.Marshal(r.String()) } - -// MillisecondOpNow return instance of [MillisecondOp] type initialized with [time.Now] -func MillisecondOpNow() MillisecondOp { return MillisecondOp{T: time.Now(), Acc: 0} } diff --git a/xlog/types_test.go b/xlog/types_test.go deleted file mode 100644 index 6e19b77..0000000 --- a/xlog/types_test.go +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright (C) 2021 - 2024 Dmitry Kolesnikov -// -// This file may be modified and distributed under the terms -// of the MIT license. See the LICENSE file for details. -// https://github.com/fogfish/logger -// - -package xlog_test - -import ( - "bytes" - "encoding/json" - "log/slog" - "regexp" - "testing" - "time" - - "github.com/fogfish/logger/v3/xlog" -) - -func TestSince(t *testing.T) { - itShouldBe(t, "since", "10[0-9].[0-9]+ms", - func(log *slog.Logger) { - v := xlog.SinceNow() - time.Sleep(100 * time.Millisecond) - log.Info("", "since", v) - }, - ) -} - -func TestPerSecond(t *testing.T) { - itShouldBe(t, "rate", "1[0-9][0-9][0-9][0-9].[0-9]+", - func(log *slog.Logger) { - v := xlog.PerSecondNow() - v.Acc += 1000 - time.Sleep(90 * time.Millisecond) - log.Info("", "rate", v) - }, - ) -} - -func TestMillisecondOp(t *testing.T) { - itShouldBe(t, "ms/op", "1[0-9][0-9]", - func(log *slog.Logger) { - v := xlog.MillisecondOpNow() - v.Acc += 1 - time.Sleep(100 * time.Millisecond) - log.Info("", "ms/op", v) - }, - ) -} - -//------------------------------------------------------------------------------ - -func itShouldBe(t *testing.T, key string, expected string, f func(log *slog.Logger)) { - t.Helper() - - buf := &bytes.Buffer{} - log := slog.New(slog.NewJSONHandler(buf, nil)) - - f(log) - - var val map[string]any - if err := json.Unmarshal(buf.Bytes(), &val); err != nil { - t.Errorf("invalid format, json expected %v", buf.String()) - } - - v, has := val[key] - if !has { - t.Errorf("no `%s` found in %v", key, val) - } - - if matched, err := regexp.MatchString(expected, v.(string)); err != nil || !matched { - t.Errorf("invalid `%s` value %v, expected %s", key, v, expected) - } -}