From 6e31ef2279da0f38861c5fa42000878f6905e46b Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Thu, 13 Nov 2025 17:21:58 +0500 Subject: [PATCH 1/8] feat: initial implementation of go-tlog with examples, CI and README Initial project setup: - Added logger core (level, format, stacktrace, internal outputs) - Added tests for core logger functionality - Added examples (stdout, stderr, file) - Added GitHub Actions CI workflow - Added README and LICENSE (BSD 2-Clause) - Added .golangci.yml and .gitignore --- .github/workflows/ci.yml | 28 ++ .gitignore | 4 + .golangci.yml | 48 +++ CHANGELOG.md | 14 + LICENCE | 25 ++ Makefile | 50 +++ README.md | 188 ++++++++++++ _examples/file/main.go | 27 ++ _examples/multi/main.go | 25 ++ _examples/stderr/main.go | 24 ++ _examples/stdout/main.go | 24 ++ format.go | 13 + go.mod | 11 + go.sum | 10 + internal/outputs/outputs.go | 102 +++++++ internal/outputs/outputs_test.go | 150 +++++++++ internal/slog/attr.go | 14 + internal/slog/buffer.go | 66 ++++ internal/slog/handler.go | 402 +++++++++++++++++++++++++ internal/slog/json_handler.go | 109 +++++++ internal/slog/record.go | 25 ++ internal/slog/text_handler.go | 164 ++++++++++ internal/slog/text_handler_test.go | 40 +++ internal/slog/value.go | 61 ++++ internal/stacktrace/stacktrace.go | 63 ++++ internal/stacktrace/stacktrace_test.go | 30 ++ level.go | 19 ++ logger.go | 125 ++++++++ logger_test.go | 207 +++++++++++++ stacktrace.go | 33 ++ 30 files changed, 2101 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CHANGELOG.md create mode 100644 LICENCE create mode 100644 Makefile create mode 100644 _examples/file/main.go create mode 100644 _examples/multi/main.go create mode 100644 _examples/stderr/main.go create mode 100644 _examples/stdout/main.go create mode 100644 format.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/outputs/outputs.go create mode 100644 internal/outputs/outputs_test.go create mode 100644 internal/slog/attr.go create mode 100644 internal/slog/buffer.go create mode 100644 internal/slog/handler.go create mode 100644 internal/slog/json_handler.go create mode 100644 internal/slog/record.go create mode 100644 internal/slog/text_handler.go create mode 100644 internal/slog/text_handler_test.go create mode 100644 internal/slog/value.go create mode 100644 internal/stacktrace/stacktrace.go create mode 100644 internal/stacktrace/stacktrace_test.go create mode 100644 level.go create mode 100644 logger.go create mode 100644 logger_test.go create mode 100644 stacktrace.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..85aaff3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ '**' ] + workflow_dispatch: + +jobs: + test: + name: Go tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24.x' + check-latest: true + + - name: Download deps + run: go mod download + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f4010c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# IDE +.idea + +coverage.out \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ae427a3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,48 @@ +run: + timeout: 5m + modules-download-mode: readonly + +issues: + exclude-use-default: false + max-same-issues: 0 + max-issues-per-linter: 0 + +linters: + enable: + - govet + - staticcheck + - revive + - errcheck + - gosimple + - ineffassign + - unused + - typecheck + - gofmt + - goimports + - misspell + - gocritic + +linters-settings: + revive: + severity: warning + rules: + - name: unused-parameter + - name: unreachable-code + - name: indent-error-flow + - name: exported + - name: blank-imports + - name: var-declaration + - name: if-return + + misspell: + locale: US + + goimports: + local-prefixes: github.com/tarantool/go-tlog + +# Skip linters on example files and any generated code +exclude-rules: + - path: ^_examples/ + linters: [revive, staticcheck, govet, gocritic] + - path: \.pb\.go$|_gen\.go$ + linters: [all] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..60edfa4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Fixed \ No newline at end of file diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..8f5ce5a --- /dev/null +++ b/LICENCE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2025, Tarantool AUTHORS +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c73704 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +# Default Go toolchain +GO ?= go +GOLANGCI_LINT ?= golangci-lint +PKG := github.com/tarantool/go-tlog + +.PHONY: all test test-race test-coverage lint fmt tidy examples help + +all: test + +## Run tests +test: + $(GO) test ./... + +## Run tests with race detector +test-race: + $(GO) test -race ./... + +## Run tests with coverage +test-coverage: + $(GO) test -covermode=atomic -coverprofile=coverage.out ./... + +## Run golangci-lint +lint: + $(GOLANGCI_LINT) run ./... + +## Format source code +fmt: + $(GO) fmt ./... + +## Tidy go.mod / go.sum +tidy: + $(GO) mod tidy + +## Run all _examples to ensure they compile and run without panic +examples: + $(GO) run ./_examples/stdout + $(GO) run ./_examples/stderr >/dev/null 2>&1 || true + $(GO) run ./_examples/file + $(GO) run ./_examples/multi + +## Show available targets +help: + @echo "Available targets:" + @echo " make test - run tests" + @echo " make test-race - run tests with -race" + @echo " make test-coverage - run tests with coverage" + @echo " make lint - run golangci-lint" + @echo " make fmt - format sources (gofmt)" + @echo " make tidy - go mod tidy" + @echo " make examples - run all examples" diff --git a/README.md b/README.md index 0b86c80..d87cd50 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ +[![CI](https://github.com/tarantool/go-tlog/actions/workflows/ci.yml/badge.svg)](https://github.com/tarantool/go-tlog/actions/workflows/ci.yml) • +[Telegram EN](https://t.me/tarantool) • +[Telegram RU](https://t.me/tarantoolru) + +--- + +

+ +

+ # go-tlog + +`go-tlog` is a lightweight and configurable logging library for Go applications. +It provides structured logging with multiple output destinations, flexible formatting, +and fine-grained log-level control. + +--- + +## Features + +- Simple setup via configuration struct +- Text or JSON output formats +- Multiple output targets: **stdout**, **stderr**, **files** +- Log levels: `Trace`, `Debug`, `Info`, `Warn`, `Error` +- Automatic timestamp, source file, and line number +- Stacktrace for errors + +--- + +## Installation + +```bash +go get github.com/tarantool/go-tlog@latest +``` + +Then import: + +```go +import "github.com/tarantool/go-tlog" +``` + +--- + +## Quick start + +```go +package main + +import "github.com/tarantool/go-tlog" + +func main() { + log, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "stdout", + }) + if err != nil { + panic(err) + } + defer log.Close() + + logger := log.Logger().With(tlog.String("component", "demo")) + logger.Info("service started", "port", 8080) + logger.Error("failed to connect", "err", "timeout") +} +``` + +Output: + +``` +2025-11-10T13:30:01+05:00 INFO service started component=demo port=8080 +2025-11-10T13:30:01+05:00 ERROR failed to connect err=timeout component=demo stacktrace="..." +``` + +--- + +## Configuration + +### `type Opts` + +```go +type Opts struct { + Level Level // minimal log level + Format Format // FormatText or FormatJSON + Path string // comma-separated outputs: "stdout,/var/log/app.log" +} +``` + +### Main API + +| Function | Description | +|------------------|------------------------------------------| +| `tlog.New(opts)` | Create a new logger | +| `Logger()` | Return the underlying logger for use | +| `Close()` | Flush buffers and close file descriptors | + +--- + +## Log levels + +| Level | When to use | +|---------|---------------------------------------------| +| `Trace` | Low-level tracing | +| `Debug` | Debugging information | +| `Info` | Normal operational messages | +| `Warn` | Non-fatal warnings | +| `Error` | Errors and exceptions (includes stacktrace) | + +--- + +## Output formats + +| Format | Example | +|--------------|---------------------------------------------------------------| +| `FormatText` | `2025-11-10T13:31:45+05:00 INFO message key=value` | +| `FormatJSON` | `{"time":"...","level":"INFO","msg":"message","key":"value"}` | + +--- + +## Output destinations + +You can specify multiple targets separated by commas: + +```go +Path: "stdout,/tmp/app.log" +``` + +Supported targets: + +- `stdout` +- `stderr` +- File paths (created automatically if not present) + +--- + +## Examples + +Ready-to-run examples are located in the `_examples/` directory: + +``` +_examples/ + ├── stdout/ + │ └── main.go + ├── stderr/ + │ └── main.go + ├── file/ + │ └── main.go + └── multi/ + └── main.go +``` + +Run examples: + +```bash +# Example 1 — log to STDOUT in text format +go run ./_examples/stdout + +# Example 2 — log to STDERR in JSON format +# Redirect stderr to a file and inspect its contents +go run ./_examples/stderr 2> logs.json +cat logs.json + +# Example 3 — log to a file in /tmp directory +# The file will be created automatically if it doesn’t exist +go run ./_examples/file +cat /tmp/tlog_demo/app.log + +# Example 4 — log to multiple destinations (stdout + file) +# This writes the same log entry both to console and to /tmp/tlog_multi/app.log +go run ./_examples/multi +cat /tmp/tlog_multi/app.log +``` + +Each example demonstrates different combinations of Path, Format, and Level, +including how to log to multiple outputs at the same time. + +--- + +## Testing + +```bash +go test ./... +``` + +--- + +## License + +BSD 2-Clause License — see [LICENSE](LICENSE) diff --git a/_examples/file/main.go b/_examples/file/main.go new file mode 100644 index 0000000..4ca2b91 --- /dev/null +++ b/_examples/file/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/tarantool/go-tlog" +) + +func main() { + _ = os.MkdirAll("/tmp/tlog_demo", 0755) + + l, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "/tmp/tlog_demo/app.log", + }) + if err != nil { + panic(err) + } + defer l.Close() + + log := l.Logger().With(slog.String("mode", "file")) + log.Info("logging to file", "path", "/tmp/tlog_demo/app.log") + log.Warn("network delay", "ms", 250) + log.Error("write failed", "err", "disk quota exceeded") +} diff --git a/_examples/multi/main.go b/_examples/multi/main.go new file mode 100644 index 0000000..f199951 --- /dev/null +++ b/_examples/multi/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/tarantool/go-tlog" +) + +func main() { + _ = os.MkdirAll("/tmp/tlog_multi", 0755) + + log, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "stdout,/tmp/tlog_multi/app.log", + }) + if err != nil { + panic(err) + } + defer log.Close() + + logger := log.Logger().With(slog.String("example", "multi-output")) + logger.Info("message written to stdout and file") +} diff --git a/_examples/stderr/main.go b/_examples/stderr/main.go new file mode 100644 index 0000000..77b648a --- /dev/null +++ b/_examples/stderr/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log/slog" + + "github.com/tarantool/go-tlog" +) + +func main() { + l, err := tlog.New(tlog.Opts{ + Level: tlog.LevelDebug, + Format: tlog.FormatJSON, + Path: "stderr", + }) + if err != nil { + panic(err) + } + defer l.Close() + + log := l.Logger().With(slog.String("mode", "stderr")) + log.Debug("debug message", "module", "init") + log.Info("component loaded", "component", "api") + log.Error("unexpected nil pointer") +} diff --git a/_examples/stdout/main.go b/_examples/stdout/main.go new file mode 100644 index 0000000..45d4178 --- /dev/null +++ b/_examples/stdout/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log/slog" + + "github.com/tarantool/go-tlog" +) + +func main() { + l, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "stdout", + }) + if err != nil { + panic(err) + } + defer l.Close() + + log := l.Logger().With(slog.String("mode", "stdout")) + log.Info("service started") + log.Warn("cache warming", "duration", "1.3s") + log.Error("failed to connect", "host", "db1") +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..4fc88ac --- /dev/null +++ b/format.go @@ -0,0 +1,13 @@ +package tlog + +// Format represents logger format. +type Format int + +const ( + // FormatDefault is the default format. Logger uses FormatText as a default one. + FormatDefault Format = iota + // FormatText prints messages as a human-readable text string. + FormatText Format = iota + // FormatJSON prints each message as a JSON object. + FormatJSON +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0dd7688 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/tarantool/go-tlog + +go 1.24 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/outputs/outputs.go b/internal/outputs/outputs.go new file mode 100644 index 0000000..21d18f1 --- /dev/null +++ b/internal/outputs/outputs.go @@ -0,0 +1,102 @@ +package outputs + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" +) + +// Outputs is io.WriteCloser for multiple output paths. +type Outputs struct { + files []*os.File + w io.Writer +} + +// New creates Outputs from comma-separated string of paths. +// Use "stdout" and "stderr" for os streams and file paths for files. +func New(paths string) (*Outputs, error) { + if paths == "" { + return nil, errors.New("empty paths") + } + + slice := splitPaths(paths) + + files := make([]*os.File, 0, len(slice)) + writers := make([]io.Writer, 0, len(slice)) + + for _, path := range slice { + file, err := openFile(path) + if err != nil { + _ = multiClose(files) + + return nil, fmt.Errorf("failed to open path %q: %w", path, err) + } + + files = append(files, file) + writers = append(writers, file) + } + + return &Outputs{ + files: files, + w: io.MultiWriter(writers...), + }, nil +} + +func splitPaths(paths string) []string { + if paths == "" { + return []string{} + } + + split := strings.Split(paths, ",") + + for i, path := range split { + split[i] = strings.TrimSpace(path) + } + + return split +} + +// https://github.com/uber-go/zap/blob/6d482535bdd97f4d97b2f9573ac308f1cf9b574e/sink.go#L158 +var defaultFilePerms uint32 = 0o666 + +func openFile(path string) (*os.File, error) { + switch path { + case "stdout": + // https://github.com/uber-go/zap/blob/6d482535bdd97f4d97b2f9573ac308f1cf9b574e/sink.go#L153-L154 + return os.Stdout, nil + // https://github.com/uber-go/zap/blob/6d482535bdd97f4d97b2f9573ac308f1cf9b574e/sink.go#L155-L156 + case "stderr": + return os.Stderr, nil + case "": + return nil, errors.New("empty path") + default: + return os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, fs.FileMode(defaultFilePerms)) + } +} + +func multiClose(files []*os.File) error { + errs := make([]error, 0, len(files)) + + for _, file := range files { + switch file { + case os.Stdout, os.Stderr, nil: + continue + default: + errs = append(errs, file.Close()) + } + } + + return errors.Join(errs...) +} + +func (o *Outputs) Write(p []byte) (int, error) { + return o.w.Write(p) +} + +// Close closes all file outputs except stdout and stderr. +func (o *Outputs) Close() error { + return multiClose(o.files) +} diff --git a/internal/outputs/outputs_test.go b/internal/outputs/outputs_test.go new file mode 100644 index 0000000..f411d0d --- /dev/null +++ b/internal/outputs/outputs_test.go @@ -0,0 +1,150 @@ +package outputs_test + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-tlog/internal/outputs" +) + +// Assert *outputs.Outputs implements io.WriteCloser. +var _ io.WriteCloser = (*outputs.Outputs)(nil) + +func Test_New_BadPath(t *testing.T) { + require := require.New(t) + + _, err := outputs.New("/not/exist") + require.ErrorContains(err, "open /not/exist: no such file or directory") +} + +func Test_New_MultipleWithBadPath(t *testing.T) { + require := require.New(t) + + _, err := outputs.New("/dev/null,/not/exist") + require.ErrorContains(err, "open /not/exist: no such file or directory") +} + +func Test_New_EmptyPaths(t *testing.T) { + require := require.New(t) + + _, err := outputs.New("") + require.ErrorContains(err, "empty paths") +} + +func Test_New_MultipleWithEmptyPath(t *testing.T) { + require := require.New(t) + + _, err := outputs.New("/dev/null,") + require.ErrorContains(err, "empty path") +} + +func Test_Outputs_Std(t *testing.T) { + for _, tc := range []string{"stdout", "stderr"} { + t.Run(tc, func(t *testing.T) { + require := require.New(t) + + r, w, _ := os.Pipe() + + switch tc { + case "stdout": + orig := os.Stdout + os.Stdout = w + + defer func() { + os.Stdout = orig + }() + case "stderr": + orig := os.Stderr + os.Stderr = w + + defer func() { + os.Stderr = orig + }() + } + + outputs, err := outputs.New(tc) + require.NoError(err) + + _, err = outputs.Write([]byte("log_message")) + require.NoError(err) + + _ = w.Close() + + out, err := io.ReadAll(r) + require.NoError(err) + require.Contains(string(out), "log_message") + }) + } +} + +func Test_Outputs_File(t *testing.T) { + require := require.New(t) + + filename := filepath.Join(os.TempDir(), "Test_Outputs_File.log") + defer func(name string) { + _ = os.Remove(name) + }(filename) + + outputs, err := outputs.New(filename) + require.NoError(err) + + _, err = outputs.Write([]byte("log_message")) + require.NoError(err) + + out, err := os.ReadFile(filename) + require.NoError(err) + require.Contains(string(out), "log_message") +} + +func Test_Outputs_Multiple(t *testing.T) { + require := require.New(t) + + // Prepare file 1. + filename1 := filepath.Join(os.TempDir(), "Test_Outputs_Multiple1.log") + defer func(name string) { + _ = os.Remove(name) + }(filename1) + + // Prepare stdout. + r, w, _ := os.Pipe() + + origStdout := os.Stdout + os.Stdout = w + + defer func() { + os.Stdout = origStdout + }() + + // Prepare file 2. + filename2 := filepath.Join(os.TempDir(), "Test_Outputs_Multiple2.log") + defer func(name string) { + _ = os.Remove(name) + }(filename2) + + outputs, err := outputs.New(filename1 + ",stdout," + filename2) + require.NoError(err) + + _, err = outputs.Write([]byte("log_message")) + require.NoError(err) + + // Assert file 1 contents. + file1Out, err := os.ReadFile(filename1) + require.NoError(err) + require.Contains(string(file1Out), "log_message") + + // Assert stdout contents. + _ = w.Close() + + stdOut, err := io.ReadAll(r) + require.NoError(err) + require.Contains(string(stdOut), "log_message") + + // Assert file 2 contents. + file2Out, err := os.ReadFile(filename2) + require.NoError(err) + require.Contains(string(file2Out), "log_message") +} diff --git a/internal/slog/attr.go b/internal/slog/attr.go new file mode 100644 index 0000000..2046901 --- /dev/null +++ b/internal/slog/attr.go @@ -0,0 +1,14 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "log/slog" +) + +func attrIsEmpty(a slog.Attr) bool { + return a.Key == "" && a.Value.Uint64() == 0 && a.Value.Any() == nil +} diff --git a/internal/slog/buffer.go b/internal/slog/buffer.go new file mode 100644 index 0000000..c428709 --- /dev/null +++ b/internal/slog/buffer.go @@ -0,0 +1,66 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import "sync" + +// buffer is a byte buffer. +// +// This implementation is adapted from the unexported type buffer +// in go/src/fmt/print.go. +type buffer []byte + +// Having an initial size gives a dramatic speedup. +var bufPool = sync.Pool{ + New: func() any { + b := make([]byte, 0, 1024) + return (*buffer)(&b) + }, +} + +func newBuffer() *buffer { + return bufPool.Get().(*buffer) +} + +func (b *buffer) Free() { + // To reduce peak allocation, return only smaller buffers to the pool. + const maxBufferSize = 16 << 10 + if cap(*b) <= maxBufferSize { + *b = (*b)[:0] + bufPool.Put(b) + } +} + +func (b *buffer) Reset() { + b.SetLen(0) +} + +func (b *buffer) Write(p []byte) (int, error) { + *b = append(*b, p...) + return len(p), nil +} + +func (b *buffer) WriteString(s string) (int, error) { + *b = append(*b, s...) + return len(s), nil +} + +func (b *buffer) WriteByte(c byte) error { + *b = append(*b, c) + return nil +} + +func (b *buffer) String() string { + return string(*b) +} + +func (b *buffer) Len() int { + return len(*b) +} + +func (b *buffer) SetLen(n int) { + *b = (*b)[:n] +} diff --git a/internal/slog/handler.go b/internal/slog/handler.go new file mode 100644 index 0000000..3a11ac5 --- /dev/null +++ b/internal/slog/handler.go @@ -0,0 +1,402 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "fmt" + "io" + "log/slog" + "reflect" + "slices" + "strconv" + "sync" + "time" +) + +// HandlerOptions are options for a [TextHandler] or [JSONHandler]. +// A zero HandlerOptions consists entirely of default values. +type HandlerOptions struct { + slog.HandlerOptions + + // OmitBuiltinKeys removes "key=" parts of output for + // time, source, level and message. + OmitBuiltinKeys bool +} + +type commonHandler struct { + opts HandlerOptions + preformattedAttrs []byte + // groupPrefix is for the text handler only. + // It holds the prefix for groups that were already pre-formatted. + // A group will appear here when a call to WithGroup is followed by + // a call to WithAttrs. + groupPrefix string + groups []string // all groups started from WithGroup + nOpenGroups int // the number of groups opened in preformattedAttrs + mu *sync.Mutex + w io.Writer +} + +func (h *commonHandler) clone() *commonHandler { + // We can't use assignment because we can't copy the mutex. + return &commonHandler{ + opts: h.opts, + preformattedAttrs: slices.Clip(h.preformattedAttrs), + groupPrefix: h.groupPrefix, + groups: slices.Clip(h.groups), + nOpenGroups: h.nOpenGroups, + w: h.w, + mu: h.mu, // mutex shared among all clones of this handler + } +} + +// enabled reports whether l is greater than or equal to the +// minimum level. +func (h *commonHandler) enabled(l slog.Level) bool { + minLevel := slog.LevelInfo + if h.opts.Level != nil { + minLevel = h.opts.Level.Level() + } + return l >= minLevel +} + +func (h *commonHandler) withAttrs(as []slog.Attr) *commonHandler { + // We are going to ignore empty groups, so if the entire slice consists of + // them, there is nothing to do. + if countEmptyGroups(as) == len(as) { + return h + } + h2 := h.clone() + // Pre-format the attributes as an optimization. + state := h2.newHandleState((*buffer)(&h2.preformattedAttrs), false, "") + defer state.free() + state.prefix.WriteString(h.groupPrefix) + if pfa := h2.preformattedAttrs; len(pfa) > 0 { + state.sep = h.attrSep() + } + // Remember the position in the buffer, in case all attrs are empty. + pos := state.buf.Len() + state.openGroups() + if !state.appendAttrs(as) { + state.buf.SetLen(pos) + } else { + // Remember the new prefix for later keys. + h2.groupPrefix = state.prefix.String() + // Remember how many opened groups are in preformattedAttrs, + // so we don't open them again when we handle a Record. + h2.nOpenGroups = len(h2.groups) + } + return h2 +} + +func (h *commonHandler) withGroup(name string) *commonHandler { + h2 := h.clone() + h2.groups = append(h2.groups, name) + return h2 +} + +// handle is the internal implementation of Handler.Handle +// used by TextHandler and JSONHandler. +func (h *commonHandler) handle(r slog.Record) error { + state := h.newHandleState(newBuffer(), true, "") + defer state.free() + // Built-in attributes. They are not in a group. + stateGroups := state.groups + state.groups = nil // So ReplaceAttrs sees no groups instead of the pre groups. + rep := h.opts.ReplaceAttr + // time + if !r.Time.IsZero() { + key := slog.TimeKey + val := r.Time.Round(0) // strip monotonic to match Attr behavior + if rep == nil { + state.appendKey(key) + state.appendTime(val) + } else { + state.appendAttr(slog.Time(key, val)) + } + } + // level + key := slog.LevelKey + val := r.Level + if rep == nil { + state.appendKey(key) + state.appendString(val.String()) + } else { + state.appendAttr(slog.Any(key, val)) + } + // source + if h.opts.AddSource { + state.appendAttr(slog.Any(slog.SourceKey, source(r))) + } + key = slog.MessageKey + msg := r.Message + if rep == nil { + state.appendKey(key) + state.appendString(msg) + } else { + state.appendAttr(slog.String(key, msg)) + } + state.groups = stateGroups // Restore groups passed to ReplaceAttrs. + state.appendNonBuiltIns(r) + state.buf.WriteByte('\n') + + h.mu.Lock() + defer h.mu.Unlock() + _, err := h.w.Write(*state.buf) + return err +} + +func (s *handleState) appendNonBuiltIns(r slog.Record) { + // preformatted Attrs + if pfa := s.h.preformattedAttrs; len(pfa) > 0 { + s.buf.WriteString(s.sep) + s.buf.Write(pfa) + s.sep = s.h.attrSep() + } + // Attrs in Record -- unlike the built-in ones, they are in groups started + // from WithGroup. + // If the record has no Attrs, don't output any groups. + if r.NumAttrs() > 0 { + s.prefix.WriteString(s.h.groupPrefix) + // The group may turn out to be empty even though it has attrs (for + // example, ReplaceAttr may delete all the attrs). + // So remember where we are in the buffer, to restore the position + // later if necessary. + pos := s.buf.Len() + s.openGroups() + empty := true + r.Attrs(func(a slog.Attr) bool { + if s.appendAttr(a) { + empty = false + } + return true + }) + if empty { + s.buf.SetLen(pos) + } + } +} + +// attrSep returns the separator between attributes. +func (h *commonHandler) attrSep() string { + return " " +} + +// handleState holds state for a single call to commonHandler.handle. +// The initial value of sep determines whether to emit a separator +// before the next key, after which it stays true. +type handleState struct { + h *commonHandler + buf *buffer + freeBuf bool // should buf be freed? + sep string // separator to write before next key + prefix *buffer // for text: key prefix + groups *[]string // pool-allocated slice of active groups, for ReplaceAttr +} + +var groupPool = sync.Pool{New: func() any { + s := make([]string, 0, 10) + return &s +}} + +func (h *commonHandler) newHandleState(buf *buffer, freeBuf bool, sep string) handleState { + s := handleState{ + h: h, + buf: buf, + freeBuf: freeBuf, + sep: sep, + prefix: newBuffer(), + } + if h.opts.ReplaceAttr != nil { + s.groups = groupPool.Get().(*[]string) + *s.groups = append(*s.groups, h.groups[:h.nOpenGroups]...) + } + return s +} + +func (s *handleState) free() { + if s.freeBuf { + s.buf.Free() + } + if gs := s.groups; gs != nil { + *gs = (*gs)[:0] + groupPool.Put(gs) + } + s.prefix.Free() +} + +func (s *handleState) openGroups() { + for _, n := range s.h.groups[s.h.nOpenGroups:] { + s.openGroup(n) + } +} + +// Separator for group names and keys. +const keyComponentSep = '.' + +// openGroup starts a new group of attributes +// with the given name. +func (s *handleState) openGroup(name string) { + s.prefix.WriteString(name) + s.prefix.WriteByte(keyComponentSep) + // Collect group names for ReplaceAttr. + if s.groups != nil { + *s.groups = append(*s.groups, name) + } +} + +// closeGroup ends the group with the given name. +func (s *handleState) closeGroup(name string) { + (*s.prefix) = (*s.prefix)[:len(*s.prefix)-len(name)-1 /* for keyComponentSep */] + s.sep = s.h.attrSep() + if s.groups != nil { + *s.groups = (*s.groups)[:len(*s.groups)-1] + } +} + +// appendAttrs appends the slice of Attrs. +// It reports whether something was appended. +func (s *handleState) appendAttrs(as []slog.Attr) bool { + nonEmpty := false + for _, a := range as { + if s.appendAttr(a) { + nonEmpty = true + } + } + return nonEmpty +} + +// appendAttr appends the Attr's key and value. +// It handles replacement and checking for an empty key. +// It reports whether something was appended. +func (s *handleState) appendAttr(a slog.Attr) bool { + a.Value = a.Value.Resolve() + if rep := s.h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != slog.KindGroup { + var gs []string + if s.groups != nil { + gs = *s.groups + } + // a.Value is resolved before calling ReplaceAttr, so the user doesn't have to. + a = rep(gs, a) + // The ReplaceAttr function may return an unresolved Attr. + a.Value = a.Value.Resolve() + } + // Elide empty Attrs. + if attrIsEmpty(a) { + return false + } + // Special case: Source. + if v := a.Value; v.Kind() == slog.KindAny { + if src, ok := v.Any().(*slog.Source); ok { + a.Value = slog.StringValue(fmt.Sprintf("%s:%d", src.File, src.Line)) + } + } + if a.Value.Kind() == slog.KindGroup { + attrs := a.Value.Group() + // Output only non-empty groups. + if len(attrs) > 0 { + // The group may turn out to be empty even though it has attrs (for + // example, ReplaceAttr may delete all the attrs). + // So remember where we are in the buffer, to restore the position + // later if necessary. + pos := s.buf.Len() + // Inline a group with an empty key. + if a.Key != "" { + s.openGroup(a.Key) + } + if !s.appendAttrs(attrs) { + s.buf.SetLen(pos) + return false + } + if a.Key != "" { + s.closeGroup(a.Key) + } + } + } else { + s.appendKey(a.Key) + s.appendValue(a.Value) + } + return true +} + +func (s *handleState) appendError(err error) { + s.appendString(fmt.Sprintf("!ERROR:%v", err)) +} + +func (s *handleState) appendKey(key string) { + s.buf.WriteString(s.sep) + s.writeKey(key) + s.sep = s.h.attrSep() +} + +func (s *handleState) writeKey(key string) { + if s.h.opts.OmitBuiltinKeys && isBuiltinKey(key) { + return + } + + if s.prefix != nil && len(*s.prefix) > 0 { + // TODO: optimize by avoiding allocation. + s.appendString(string(*s.prefix) + key) + } else { + s.appendString(key) + } + s.buf.WriteByte('=') +} + +func isBuiltinKey(key string) bool { + return (key == slog.TimeKey) || (key == slog.LevelKey) || + (key == slog.SourceKey) || (key == slog.MessageKey) +} + +func (s *handleState) appendString(str string) { + if needsQuoting(str) { + *s.buf = strconv.AppendQuote(*s.buf, str) + } else { + s.buf.WriteString(str) + } +} + +func (s *handleState) appendValue(v slog.Value) { + defer func() { + if r := recover(); r != nil { + // If it panics with a nil pointer, the most likely cases are + // an encoding.TextMarshaler or error fails to guard against nil, + // in which case "" seems to be the feasible choice. + // + // Adapted from the code in fmt/print.go. + if v := reflect.ValueOf(v.Any()); v.Kind() == reflect.Pointer && v.IsNil() { + s.appendString("") + return + } + + // Otherwise just print the original panic message. + s.appendString(fmt.Sprintf("!PANIC: %v", r)) + } + }() + + var err error + err = appendTextValue(s, v) + if err != nil { + s.appendError(err) + } +} + +func (s *handleState) appendTime(t time.Time) { + *s.buf = appendRFC3339Millis(*s.buf, t) +} + +func appendRFC3339Millis(b []byte, t time.Time) []byte { + // Format according to time.RFC3339Nano since it is highly optimized, + // but truncate it to use millisecond resolution. + // Unfortunately, that format trims trailing 0s, so add 1/10 millisecond + // to guarantee that there are exactly 4 digits after the period. + const prefixLen = len("2006-01-02T15:04:05.000") + n := len(b) + t = t.Truncate(time.Millisecond).Add(time.Millisecond / 10) + b = t.AppendFormat(b, time.RFC3339Nano) + b = append(b[:n+prefixLen], b[n+prefixLen+1:]...) // drop the 4th digit + return b +} diff --git a/internal/slog/json_handler.go b/internal/slog/json_handler.go new file mode 100644 index 0000000..010daa6 --- /dev/null +++ b/internal/slog/json_handler.go @@ -0,0 +1,109 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "unicode/utf8" +) + +var safeSet = [utf8.RuneSelf]bool{ + ' ': true, + '!': true, + '"': false, + '#': true, + '$': true, + '%': true, + '&': true, + '\'': true, + '(': true, + ')': true, + '*': true, + '+': true, + ',': true, + '-': true, + '.': true, + '/': true, + '0': true, + '1': true, + '2': true, + '3': true, + '4': true, + '5': true, + '6': true, + '7': true, + '8': true, + '9': true, + ':': true, + ';': true, + '<': true, + '=': true, + '>': true, + '?': true, + '@': true, + 'A': true, + 'B': true, + 'C': true, + 'D': true, + 'E': true, + 'F': true, + 'G': true, + 'H': true, + 'I': true, + 'J': true, + 'K': true, + 'L': true, + 'M': true, + 'N': true, + 'O': true, + 'P': true, + 'Q': true, + 'R': true, + 'S': true, + 'T': true, + 'U': true, + 'V': true, + 'W': true, + 'X': true, + 'Y': true, + 'Z': true, + '[': true, + '\\': false, + ']': true, + '^': true, + '_': true, + '`': true, + 'a': true, + 'b': true, + 'c': true, + 'd': true, + 'e': true, + 'f': true, + 'g': true, + 'h': true, + 'i': true, + 'j': true, + 'k': true, + 'l': true, + 'm': true, + 'n': true, + 'o': true, + 'p': true, + 'q': true, + 'r': true, + 's': true, + 't': true, + 'u': true, + 'v': true, + 'w': true, + 'x': true, + 'y': true, + 'z': true, + '{': true, + '|': true, + '}': true, + '~': true, + '\u007f': true, +} diff --git a/internal/slog/record.go b/internal/slog/record.go new file mode 100644 index 0000000..fe55d48 --- /dev/null +++ b/internal/slog/record.go @@ -0,0 +1,25 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "log/slog" + "runtime" +) + +// source returns a Source for the log event. +// If the Record was created without the necessary information, +// or if the location is unavailable, it returns a non-nil *Source +// with zero fields. +func source(r slog.Record) *slog.Source { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + return &slog.Source{ + Function: f.Function, + File: f.File, + Line: f.Line, + } +} diff --git a/internal/slog/text_handler.go b/internal/slog/text_handler.go new file mode 100644 index 0000000..3aeb0c7 --- /dev/null +++ b/internal/slog/text_handler.go @@ -0,0 +1,164 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "context" + "encoding" + "fmt" + "io" + "log/slog" + "reflect" + "strconv" + "sync" + "unicode" + "unicode/utf8" +) + +// TextHandler is a [Handler] that writes Records to an [io.Writer] as a +// sequence of key=value pairs separated by spaces and followed by a newline. +type TextHandler struct { + *commonHandler +} + +// NewTextHandler creates a [TextHandler] that writes to w, +// using the given options. +// If opts is nil, the default options are used. +func NewTextHandler(w io.Writer, opts *HandlerOptions) *TextHandler { + if opts == nil { + opts = &HandlerOptions{} + } + return &TextHandler{ + &commonHandler{ + w: w, + opts: *opts, + mu: &sync.Mutex{}, + }, + } +} + +// Enabled reports whether the handler handles records at the given level. +// The handler ignores records whose level is lower. +func (h *TextHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.commonHandler.enabled(level) +} + +// WithAttrs returns a new [TextHandler] whose attributes consists +// of h's attributes followed by attrs. +func (h *TextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &TextHandler{commonHandler: h.commonHandler.withAttrs(attrs)} +} + +func (h *TextHandler) WithGroup(name string) slog.Handler { + return &TextHandler{commonHandler: h.commonHandler.withGroup(name)} +} + +// Handle formats its argument [Record] as a single line of space-separated +// key=value items. +// +// If the Record's time is zero, the time is omitted. +// Otherwise, the key is "time" +// and the value is output in RFC3339 format with millisecond precision. +// +// If the Record's level is zero, the level is omitted. +// Otherwise, the key is "level" +// and the value of [Level.String] is output. +// +// If the AddSource option is set and source information is available, +// the key is "source" and the value is output as FILE:LINE. +// +// The message's key is "msg". +// +// To modify these or other attributes, or remove them from the output, use +// [HandlerOptions.ReplaceAttr]. +// +// If a value implements [encoding.TextMarshaler], the result of MarshalText is +// written. Otherwise, the result of [fmt.Sprint] is written. +// +// Keys and values are quoted with [strconv.Quote] if they contain Unicode space +// characters, non-printing characters, '"' or '='. +// +// Keys inside groups consist of components (keys or group names) separated by +// dots. No further escaping is performed. +// Thus there is no way to determine from the key "a.b.c" whether there +// are two groups "a" and "b" and a key "c", or a single group "a.b" and a key "c", +// or single group "a" and a key "b.c". +// If it is necessary to reconstruct the group structure of a key +// even in the presence of dots inside components, use +// [HandlerOptions.ReplaceAttr] to encode that information in the key. +// +// Each call to Handle results in a single serialized call to +// io.Writer.Write. +func (h *TextHandler) Handle(_ context.Context, r slog.Record) error { + return h.commonHandler.handle(r) +} + +func appendTextValue(s *handleState, v slog.Value) error { + switch v.Kind() { + case slog.KindString: + s.appendString(v.String()) + case slog.KindTime: + s.appendTime(v.Time()) + case slog.KindAny: + if tm, ok := v.Any().(encoding.TextMarshaler); ok { + data, err := tm.MarshalText() + if err != nil { + return err + } + // TODO: avoid the conversion to string. + s.appendString(string(data)) + return nil + } + if bs, ok := byteSlice(v.Any()); ok { + // As of Go 1.19, this only allocates for strings longer than 32 bytes. + s.buf.WriteString(strconv.Quote(string(bs))) + return nil + } + s.appendString(fmt.Sprintf("%+v", v.Any())) + default: + *s.buf = appendValue(v, *s.buf) + } + return nil +} + +// byteSlice returns its argument as a []byte if the argument's +// underlying type is []byte, along with a second return value of true. +// Otherwise it returns nil, false. +func byteSlice(a any) ([]byte, bool) { + if bs, ok := a.([]byte); ok { + return bs, true + } + // Like Printf's %s, we allow both the slice type and the byte element type to be named. + t := reflect.TypeOf(a) + if t != nil && t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 { + return reflect.ValueOf(a).Bytes(), true + } + return nil, false +} + +func needsQuoting(s string) bool { + if len(s) == 0 { + return true + } + for i := 0; i < len(s); { + b := s[i] + if b < utf8.RuneSelf { + // Quote anything except a backslash that would need quoting in a + // JSON string, as well as space and '=' + if b != '\\' && (b == ' ' || b == '=' || !safeSet[b]) { + return true + } + i++ + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError || unicode.IsSpace(r) || !unicode.IsPrint(r) { + return true + } + i += size + } + return false +} diff --git a/internal/slog/text_handler_test.go b/internal/slog/text_handler_test.go new file mode 100644 index 0000000..a07be13 --- /dev/null +++ b/internal/slog/text_handler_test.go @@ -0,0 +1,40 @@ +package slog_test + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + slogcustom "github.com/tarantool/go-tlog/internal/slog" +) + +func Test_TextHandler_OmitBuiltinKeys(t *testing.T) { + require := require.New(t) + + var b bytes.Buffer + + handler := slogcustom.NewTextHandler(&b, &slogcustom.HandlerOptions{ + HandlerOptions: slog.HandlerOptions{AddSource: true}, + OmitBuiltinKeys: true, + }) + l := slog.New(handler) + + l.Info("my message") + + out := strings.TrimSpace(b.String()) + + require.NotContains(out, "time=") + require.Regexp(`^20\d\d\-\d\d\-\d\dT\d\d:\d\d:\d\d.*$`, out) + + require.NotContains(out, "level=") + require.Contains(out, "INFO") + + require.NotContains(out, "source=") + require.Contains(out, "internal/slog/text_handler_test.go:25") + + require.NotContains(out, "msg=") + require.Contains(out, "my message") +} diff --git a/internal/slog/value.go b/internal/slog/value.go new file mode 100644 index 0000000..7e3a29f --- /dev/null +++ b/internal/slog/value.go @@ -0,0 +1,61 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// nolint +package slog + +import ( + "fmt" + "log/slog" + "strconv" +) + +// countEmptyGroups returns the number of empty group values in its argument. +func countEmptyGroups(as []slog.Attr) int { + n := 0 + for _, a := range as { + if isEmptyGroup(a.Value) { + n++ + } + } + return n +} + +// isEmptyGroup reports whether v is a group that has no attributes. +func isEmptyGroup(v slog.Value) bool { + if v.Kind() != slog.KindGroup { + return false + } + // We do not need to recursively examine the group's Attrs for emptiness, + // because GroupValue removed them when the group was constructed, and + // groups are immutable. + return len(v.Group()) == 0 +} + +// appendValue appends a text representation of v to dst. +// v is formatted as with fmt.Sprint. +func appendValue(v slog.Value, dst []byte) []byte { + switch v.Kind() { + case slog.KindString: + return append(dst, v.String()...) + case slog.KindInt64: + return strconv.AppendInt(dst, v.Int64(), 10) + case slog.KindUint64: + return strconv.AppendUint(dst, v.Uint64(), 10) + case slog.KindFloat64: + return strconv.AppendFloat(dst, v.Float64(), 'g', -1, 64) + case slog.KindBool: + return strconv.AppendBool(dst, v.Bool()) + case slog.KindDuration: + return append(dst, v.Duration().String()...) + case slog.KindTime: + return append(dst, v.Time().String()...) + case slog.KindGroup: + return fmt.Append(dst, v.Group()) + case slog.KindAny, slog.KindLogValuer: + return fmt.Append(dst, v.Any()) + default: + panic(fmt.Sprintf("bad kind: %s", v.Kind())) + } +} diff --git a/internal/stacktrace/stacktrace.go b/internal/stacktrace/stacktrace.go new file mode 100644 index 0000000..f8937a0 --- /dev/null +++ b/internal/stacktrace/stacktrace.go @@ -0,0 +1,63 @@ +package stacktrace + +import ( + "runtime" + "strconv" + "strings" +) + +// Get returns a formatted stacktrace starting from the specified number of frames to skip. +func Get(skip int) string { + frames := getFrames(skip) + + var b strings.Builder + + for { + frame, more := frames.Next() + + writeFrame(&b, frame) + + if !more { + break + } + + b.WriteByte('\n') + } + + return b.String() +} + +const ( + defaultProgramCounters = 64 + + // runtime.Counters, Get and getFrames. + baseNestingLevel = 3 + + pcsExtendFactor = 2 +) + +func getFrames(skip int) *runtime.Frames { + pcs := make([]uintptr, defaultProgramCounters) + + for { + n := runtime.Callers(baseNestingLevel+skip, pcs) + if n < cap(pcs) { + pcs = pcs[:n] + + break + } + + pcs = make([]uintptr, len(pcs)*pcsExtendFactor) + } + + return runtime.CallersFrames(pcs) +} + +func writeFrame(b *strings.Builder, frame runtime.Frame) { + b.WriteString(frame.Function) + b.WriteByte('\n') + b.WriteByte('\t') + b.WriteString(frame.File) + b.WriteByte(':') + b.WriteString(strconv.Itoa(frame.Line)) +} diff --git a/internal/stacktrace/stacktrace_test.go b/internal/stacktrace/stacktrace_test.go new file mode 100644 index 0000000..a5505c4 --- /dev/null +++ b/internal/stacktrace/stacktrace_test.go @@ -0,0 +1,30 @@ +package stacktrace_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-tlog/internal/stacktrace" +) + +func funcNested() string { + // Skip funcNested, include funcWrapper. + return stacktrace.Get(1) +} + +func funcWrapper() string { + return funcNested() +} + +func Test_Get(t *testing.T) { + require := require.New(t) + + stack := funcWrapper() + + require.Contains(stack, "internal/stacktrace/stacktrace_test.go:17") + require.Contains(stack, "funcWrapper") + + require.NotContains(stack, "internal/stacktrace/stacktrace_test.go:12") + require.NotContains(stack, "funcNested") +} diff --git a/level.go b/level.go new file mode 100644 index 0000000..c6ef86c --- /dev/null +++ b/level.go @@ -0,0 +1,19 @@ +package tlog + +// Level represents logger level. +type Level int + +const ( + // LevelDefault is the default level. Logger uses LevelInfo as a default one. + LevelDefault Level = iota + // LevelTrace prints messages up to Debug. Messages up to Debug have stacktraces. + LevelTrace + // LevelDebug prints messages up to Debug. Messages up to Error have stacktraces. + LevelDebug + // LevelInfo prints messages up to Info. Messages up to Error have stacktraces. + LevelInfo + // LevelWarn prints messages up to Warn. Messages up to Error have stacktraces. + LevelWarn + // LevelError prints messages up to Error. Messages up to Error have stacktraces. + LevelError +) diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..a2ad7fc --- /dev/null +++ b/logger.go @@ -0,0 +1,125 @@ +package tlog + +import ( + "fmt" + "log/slog" + "time" + + "github.com/tarantool/go-tlog/internal/outputs" + slogcustom "github.com/tarantool/go-tlog/internal/slog" +) + +// Logger contains slog.Logger for several outputs +// and method to close these outputs. +type Logger struct { + outputs *outputs.Outputs + logger *slog.Logger +} + +// Opts are New options. +type Opts struct { + // Level sets minimum level for the logger. + Level Level + // Format sets log format. + Format Format + // Path is comma-separated list of log outputs. + // Use "stdout" and "stderr" for os streams and file paths for files. + // Default is "stderr". + Path string +} + +// New creates a new Logger with the given options. +// It configures level, format and output destinations and returns +// a ready-to-use logger instance. +func New(opts Opts) (*Logger, error) { + var ( + logLevel slog.Level + traceLevel slog.Level + ) + + switch opts.Level { + case LevelTrace: + traceLevel = slog.LevelDebug + logLevel = slog.LevelDebug + case LevelDebug: + traceLevel = slog.LevelError + logLevel = slog.LevelDebug + case LevelDefault: + fallthrough + case LevelInfo: + traceLevel = slog.LevelError + logLevel = slog.LevelInfo + case LevelWarn: + traceLevel = slog.LevelError + logLevel = slog.LevelWarn + case LevelError: + traceLevel = slog.LevelError + logLevel = slog.LevelError + } + + if opts.Path == "" { + // https://github.com/uber-go/zap/blob/6d482535bdd97f4d97b2f9573ac308f1cf9b574e/config.go#L167C31-L167C37 + opts.Path = "stderr" + } + + outs, err := outputs.New(opts.Path) + if err != nil { + return nil, fmt.Errorf("failed to create outputs: %w", err) + } + + handlerOpts := slog.HandlerOptions{ + Level: logLevel, + ReplaceAttr: replaceAttr, + AddSource: true, + } + + var baseHandler slog.Handler + + switch opts.Format { + case FormatDefault: + fallthrough + case FormatText: + baseHandler = slogcustom.NewTextHandler(outs, &slogcustom.HandlerOptions{ + HandlerOptions: handlerOpts, + OmitBuiltinKeys: true, + }) + case FormatJSON: + baseHandler = slog.NewJSONHandler(outs, &handlerOpts) + } + + handler := newStacktraceHandler(baseHandler, traceLevel) + l := slog.New(handler) + + return &Logger{ + outputs: outs, + logger: l, + }, nil +} + +func replaceAttr(group []string, a slog.Attr) slog.Attr { + switch a.Key { + case slog.TimeKey: + return replaceTime(group, a) + default: + return a + } +} + +func replaceTime(_ []string, a slog.Attr) slog.Attr { + t := a.Value.Time() + + a.Value = slog.StringValue(t.Format(time.RFC3339)) + + return a +} + +// Logger returns the underlying slog.Logger instance. +// It can be used directly to log messages with additional attributes. +func (l *Logger) Logger() *slog.Logger { + return l.logger +} + +// Close flushes all pending log entries and closes all opened outputs. +func (l *Logger) Close() error { + return l.outputs.Close() +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..b42724e --- /dev/null +++ b/logger_test.go @@ -0,0 +1,207 @@ +package tlog_test + +import ( + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-tlog" +) + +func Test_Logger(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + // Test assertions expect single file opts.Path. + opts tlog.Opts + log func(l *slog.Logger) + assert func(require *require.Assertions, logs string) + }{ + { + name: "InfoMessage_DebugTextLogger", + opts: tlog.Opts{ + Level: tlog.LevelDebug, + Format: tlog.FormatText, + Path: "InfoMessage_DebugPlainLogger.log", + }, + log: func(l *slog.Logger) { + l.Info("my info message") + // Example: + // 2025-02-19T13:51:31+03:00 INFO tlog_test.go: "my info message" + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, "my info message") + require.NotContains(logs, "stacktrace=") + }, + }, + { + name: "ErrorMessage_DebugTextLogger", + opts: tlog.Opts{ + Level: tlog.LevelDebug, + Format: tlog.FormatText, + Path: "ErrorMessage_DebugPlainLogger.log", + }, + log: func(l *slog.Logger) { + l.Error("my error message") + // 2025-02-19T13:52:11+03:00 ERROR logger_test.go:46 "my error message" + // stacktrace="github.com/tarantool/go-tlog_test.Test_Logger.funcY + // logger_test.go: + // logger_test.Test_Logger.funcX + // logger_test.go: + // testing.tRunner + // /usr/local/go/src/testing/testing.go: + // runtime.goexit + // /usr/local/go/src/runtime/asm_amd64.s:" + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, "my error message") + require.Contains(logs, "stacktrace=") + require.Contains(logs, "tlog_test.Test_Logger") + }, + }, + { + name: "InfoMessage_ErrorTextLogger", + opts: tlog.Opts{ + Level: tlog.LevelError, + Format: tlog.FormatText, + Path: "InfoMessage_ErrorPlainLogger.log", + }, + log: func(l *slog.Logger) { + l.Info("my info message") + }, + assert: func(require *require.Assertions, logs string) { + require.NotContains(logs, "my info message") + }, + }, + { + name: "InfoMessage_TraceTextLogger", + opts: tlog.Opts{ + Level: tlog.LevelTrace, + Format: tlog.FormatText, + Path: "InfoMessage_TraceTextLogger.log", + }, + log: func(l *slog.Logger) { + l.Info("my info message") + // Example (shortened): + // 2025-02-19T13:54:00+03:00 INFO tlog_test.go: "my info message" + // stacktrace="github.com/tarantool/go-tlog_test.Test_Logger.funcY + // logger_test.go: + // logger_test.Test_Logger.funcX + // logger_test.go: + // testing.tRunner + // /usr/local/go/src/testing/testing.go: + // runtime.goexit + // /usr/local/go/src/runtime/asm_amd64.s:" + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, "my info message") + require.Contains(logs, "stacktrace=") + require.Contains(logs, "tlog_test.Test_Logger") + }, + }, + { + name: "InfoMessage_DefaultLogger", + opts: tlog.Opts{ + // Level and Format will be defaulted by New. + Path: "InfoMessage_DefaultLogger.log", + }, + log: func(l *slog.Logger) { + l.Warn("my info message") + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, "my info message") + }, + }, + { + name: "InfoMessage_DebugJSONLogger", + opts: tlog.Opts{ + Level: tlog.LevelDebug, + Format: tlog.FormatJSON, + Path: "InfoMessage_DebugPlainLogger.json", + }, + log: func(l *slog.Logger) { + l.Info("my info message") + // Example (shortened): + // { + // "time":"2025-02-19T13:55:16+03:00", + // "level":"INFO", + // "source":{ + // "function":"github.com/tarantool/go-tlog_test.Test_Logger.funcZ", + // "file":"tlog_test.go", + // "line": + // }, + // "msg":"my info message" + // } + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, `"msg":"my info message"`) + require.NotContains(logs, `"stacktrace"`) + }, + }, + { + name: "ErrorMessage_DebugJSONLogger", + opts: tlog.Opts{ + Level: tlog.LevelDebug, + Format: tlog.FormatJSON, + Path: "ErrorMessage_DebugJSONLogger.json", + }, + log: func(l *slog.Logger) { + l.Error("my error message") + // Example (shortened): + // { + // "time":"2025-02-19T13:56:56+03:00", + // "level":"ERROR", + // "source":{ + // "function":"github.com/tarantool/go-tlog_test.Test_Logger.funcN", + // "file":"tlog_test.go", + // "line": + // }, + // "msg":"my error message", + // "stacktrace":"github.com/tarantool/go-tlog_test.Test_Logger.funcN\n + // \ttlog_test.go:\n + // testing.tRunner\n + // \truntime.goexit" + // } + }, + assert: func(require *require.Assertions, logs string) { + require.Contains(logs, `"msg":"my error message"`) + require.Contains(logs, `"stacktrace":"`) + require.Contains(logs, "tlog_test.Test_Logger") + }, + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := require.New(t) + + tmpDir := t.TempDir() + tc.opts.Path = filepath.Join(tmpDir, tc.opts.Path) + + l, err := tlog.New(tc.opts) + r.NoError(err) + + defer func() { + _ = l.Close() + }() + + tc.log(l.Logger()) + + logs, err := os.ReadFile(tc.opts.Path) + r.NoError(err) + + // If there are stacktraces, there are no redundant internal frames. + r.NotContains(string(logs), "slog.(*Logger).") + + tc.assert(r, string(logs)) + }) + } +} diff --git a/stacktrace.go b/stacktrace.go new file mode 100644 index 0000000..6e0e2c8 --- /dev/null +++ b/stacktrace.go @@ -0,0 +1,33 @@ +package tlog + +import ( + "context" + "log/slog" + + "github.com/tarantool/go-tlog/internal/stacktrace" +) + +type stacktraceHandler struct { + slog.Handler + + fromLevel slog.Level +} + +func newStacktraceHandler(h slog.Handler, fromLevel slog.Level) stacktraceHandler { + return stacktraceHandler{ + Handler: h, + fromLevel: fromLevel, + } +} + +// Strip stacktraceHandler.Handle, slog.(*Logger).log and +// slog.(*Logger).. +var internalsStripLevel = 3 + +func (h stacktraceHandler) Handle(ctx context.Context, record slog.Record) error { + if record.Level >= h.fromLevel { + record.Add("stacktrace", stacktrace.Get(internalsStripLevel)) + } + + return h.Handler.Handle(ctx, record) +} From e72fbb614a8b6aa366477cbc2f9bd771ceb69bbd Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 14 Nov 2025 12:43:44 +0500 Subject: [PATCH 2/8] ci, docs: rename CI workflow and update badges in README --- .github/workflows/{ci.yml => testing.yml} | 2 +- README.md | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) rename .github/workflows/{ci.yml => testing.yml} (97%) diff --git a/.github/workflows/ci.yml b/.github/workflows/testing.yml similarity index 97% rename from .github/workflows/ci.yml rename to .github/workflows/testing.yml index 85aaff3..a6a7536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/testing.yml @@ -1,4 +1,4 @@ -name: CI +name: testing on: push: diff --git a/README.md b/README.md index d87cd50..74454ad 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,10 @@ -[![CI](https://github.com/tarantool/go-tlog/actions/workflows/ci.yml/badge.svg)](https://github.com/tarantool/go-tlog/actions/workflows/ci.yml) • -[Telegram EN](https://t.me/tarantool) • -[Telegram RU](https://t.me/tarantoolru) + + + ---- - -

- -

+[![Actions Status][actions-badge]][actions-url] +[![Telegram EN][telegram-badge]][telegram-en-url] +[![Telegram RU][telegram-badge]][telegram-ru-url] # go-tlog @@ -187,3 +185,9 @@ go test ./... ## License BSD 2-Clause License — see [LICENSE](LICENSE) + +[actions-badge]: https://github.com/tarantool/go-tlog/actions/workflows/testing.yml/badge.svg +[actions-url]: https://github.com/tarantool/go-tlog/actions/workflows/testing.yml +[telegram-badge]: https://img.shields.io/badge/Telegram-join%20chat-blue.svg +[telegram-en-url]: http://telegram.me/tarantool +[telegram-ru-url]: http://telegram.me/tarantoolru From 21269a53df50ba86ffc32d18f1f79280fa21e2be Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 14 Nov 2025 13:27:24 +0500 Subject: [PATCH 3/8] docs, tests: replace runnable examples with testable example and update README --- README.md | 56 +++++++++++++++----------------------- _examples/file/main.go | 27 ------------------- _examples/multi/main.go | 25 ----------------- _examples/stderr/main.go | 24 ----------------- _examples/stdout/main.go | 24 ----------------- example_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 135 deletions(-) delete mode 100644 _examples/file/main.go delete mode 100644 _examples/multi/main.go delete mode 100644 _examples/stderr/main.go delete mode 100644 _examples/stdout/main.go create mode 100644 example_test.go diff --git a/README.md b/README.md index 74454ad..e62b6af 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,22 @@ [![Telegram EN][telegram-badge]][telegram-en-url] [![Telegram RU][telegram-badge]][telegram-ru-url] +## Table of contents + +- [go-tlog](#go-tlog) + - [Features](#features) + - [Installation](#installation) + - [Quick start](#quick-start) + - [Configuration](#configuration) + - [type Opts](#type-opts) + - [Main API](#main-api) + - [Log levels](#log-levels) + - [Output formats](#output-formats) + - [Output destinations](#output-destinations) + - [Examples](#examples) + - [Testing](#testing) + - [License](#license) + # go-tlog `go-tlog` is a lightweight and configurable logging library for Go applications. @@ -133,41 +149,11 @@ Supported targets: ## Examples -Ready-to-run examples are located in the `_examples/` directory: - -``` -_examples/ - ├── stdout/ - │ └── main.go - ├── stderr/ - │ └── main.go - ├── file/ - │ └── main.go - └── multi/ - └── main.go -``` +Included examples: -Run examples: - -```bash -# Example 1 — log to STDOUT in text format -go run ./_examples/stdout - -# Example 2 — log to STDERR in JSON format -# Redirect stderr to a file and inspect its contents -go run ./_examples/stderr 2> logs.json -cat logs.json - -# Example 3 — log to a file in /tmp directory -# The file will be created automatically if it doesn’t exist -go run ./_examples/file -cat /tmp/tlog_demo/app.log - -# Example 4 — log to multiple destinations (stdout + file) -# This writes the same log entry both to console and to /tmp/tlog_multi/app.log -go run ./_examples/multi -cat /tmp/tlog_multi/app.log -``` +- **ExampleNew_text** — basic text logger writing to stdout +- **ExampleNew_json** — JSON logging +- **ExampleNew_multi** — logging to multiple destinations (`stdout,/tmp/...`) Each example demonstrates different combinations of Path, Format, and Level, including how to log to multiple outputs at the same time. @@ -177,7 +163,7 @@ including how to log to multiple outputs at the same time. ## Testing ```bash -go test ./... +make test ``` --- diff --git a/_examples/file/main.go b/_examples/file/main.go deleted file mode 100644 index 4ca2b91..0000000 --- a/_examples/file/main.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "log/slog" - "os" - - "github.com/tarantool/go-tlog" -) - -func main() { - _ = os.MkdirAll("/tmp/tlog_demo", 0755) - - l, err := tlog.New(tlog.Opts{ - Level: tlog.LevelInfo, - Format: tlog.FormatText, - Path: "/tmp/tlog_demo/app.log", - }) - if err != nil { - panic(err) - } - defer l.Close() - - log := l.Logger().With(slog.String("mode", "file")) - log.Info("logging to file", "path", "/tmp/tlog_demo/app.log") - log.Warn("network delay", "ms", 250) - log.Error("write failed", "err", "disk quota exceeded") -} diff --git a/_examples/multi/main.go b/_examples/multi/main.go deleted file mode 100644 index f199951..0000000 --- a/_examples/multi/main.go +++ /dev/null @@ -1,25 +0,0 @@ -package main - -import ( - "log/slog" - "os" - - "github.com/tarantool/go-tlog" -) - -func main() { - _ = os.MkdirAll("/tmp/tlog_multi", 0755) - - log, err := tlog.New(tlog.Opts{ - Level: tlog.LevelInfo, - Format: tlog.FormatText, - Path: "stdout,/tmp/tlog_multi/app.log", - }) - if err != nil { - panic(err) - } - defer log.Close() - - logger := log.Logger().With(slog.String("example", "multi-output")) - logger.Info("message written to stdout and file") -} diff --git a/_examples/stderr/main.go b/_examples/stderr/main.go deleted file mode 100644 index 77b648a..0000000 --- a/_examples/stderr/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "log/slog" - - "github.com/tarantool/go-tlog" -) - -func main() { - l, err := tlog.New(tlog.Opts{ - Level: tlog.LevelDebug, - Format: tlog.FormatJSON, - Path: "stderr", - }) - if err != nil { - panic(err) - } - defer l.Close() - - log := l.Logger().With(slog.String("mode", "stderr")) - log.Debug("debug message", "module", "init") - log.Info("component loaded", "component", "api") - log.Error("unexpected nil pointer") -} diff --git a/_examples/stdout/main.go b/_examples/stdout/main.go deleted file mode 100644 index 45d4178..0000000 --- a/_examples/stdout/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "log/slog" - - "github.com/tarantool/go-tlog" -) - -func main() { - l, err := tlog.New(tlog.Opts{ - Level: tlog.LevelInfo, - Format: tlog.FormatText, - Path: "stdout", - }) - if err != nil { - panic(err) - } - defer l.Close() - - log := l.Logger().With(slog.String("mode", "stdout")) - log.Info("service started") - log.Warn("cache warming", "duration", "1.3s") - log.Error("failed to connect", "host", "db1") -} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..442e075 --- /dev/null +++ b/example_test.go @@ -0,0 +1,58 @@ +package tlog_test + +import ( + "log/slog" + + "github.com/tarantool/go-tlog" +) + +// ExampleNew_text shows how to create a text logger that writes to stdout. +func ExampleNew_text() { + log, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "stdout", + }) + if err != nil { + panic(err) + } + defer func() { _ = log.Close() }() + + logger := log.Logger().With(slog.String("component", "example_text")) + logger.Info("service started") +} + +// ExampleNew_json shows how to create a JSON logger that writes to stdout. +func ExampleNew_json() { + log, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatJSON, + Path: "stdout", + }) + if err != nil { + panic(err) + } + defer func() { _ = log.Close() }() + + logger := log.Logger().With( + slog.String("component", "example_json"), + slog.String("request_id", "abc-123"), + ) + logger.Error("request failed") +} + +// ExampleNew_multi shows how to log to multiple destinations at once. +func ExampleNew_multi() { + log, err := tlog.New(tlog.Opts{ + Level: tlog.LevelInfo, + Format: tlog.FormatText, + Path: "stdout,/tmp/tlog_example_multi.log", + }) + if err != nil { + panic(err) + } + defer func() { _ = log.Close() }() + + logger := log.Logger().With(slog.String("component", "example_multi")) + logger.Info("message written to stdout and file") +} From d39ae0ec2217d3f4b45bfca056ddd26671a10bd9 Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 21 Nov 2025 11:26:05 +0500 Subject: [PATCH 4/8] docs: initial CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60edfa4..e37c19f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Added +- Core structured logging library for Go +- Support for log levels: Trace, Debug, Info, Warn, Error +- Text and JSON output formats +- Multiple output destinations: stdout, stderr, file paths, multi-target +- Automatic timestamp, source file and line number +- Stacktrace collection for error-level messages +- Test suite for core functionality +- Idiomatic Go examples (testable examples) +- Makefile, GitHub Actions CI workflow, README, LICENSE, lint configuration ### Changed From 502d39fb0f142d2fd03e6272671a68cf71e232f4 Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 28 Nov 2025 15:53:02 +0500 Subject: [PATCH 5/8] Update CHANGELOG.md Co-authored-by: Oleg Jukovec --- CHANGELOG.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e37c19f..28a0851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,15 +8,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Added -- Core structured logging library for Go -- Support for log levels: Trace, Debug, Info, Warn, Error -- Text and JSON output formats -- Multiple output destinations: stdout, stderr, file paths, multi-target -- Automatic timestamp, source file and line number -- Stacktrace collection for error-level messages -- Test suite for core functionality -- Idiomatic Go examples (testable examples) -- Makefile, GitHub Actions CI workflow, README, LICENSE, lint configuration + +- Core structured logging library for Go. +- Support for log levels: Trace, Debug, Info, Warn, Error. +- Text and JSON output formats. +- Multiple output destinations: stdout, stderr, file paths, multi-target. +- Automatic timestamp, source file and line number. +- Stacktrace collection for error-level messages. +- Test suite for core functionality. +- Idiomatic Go examples (testable examples). +- Makefile, GitHub Actions CI workflow, README, LICENSE, lint configuration. ### Changed From 2ea515dff8fe911379df085d277f4388632d8020 Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 28 Nov 2025 15:54:07 +0500 Subject: [PATCH 6/8] Update .github/workflows/testing.yml Co-authored-by: Oleg Jukovec --- .github/workflows/testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index a6a7536..351e9eb 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -26,3 +26,6 @@ jobs: - name: Run tests run: make test + + - name: Run tests with race-detector + run: make test-race From 5341e342aab931b656d5040b32ca6deb25461f1c Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 28 Nov 2025 15:54:54 +0500 Subject: [PATCH 7/8] Update Makefile Co-authored-by: Oleg Jukovec --- Makefile | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 2c73704..06b79ee 100644 --- a/Makefile +++ b/Makefile @@ -7,38 +7,38 @@ PKG := github.com/tarantool/go-tlog all: test -## Run tests +## Run tests. test: $(GO) test ./... -## Run tests with race detector +## Run tests with race detector. test-race: $(GO) test -race ./... -## Run tests with coverage +## Run tests with coverage. test-coverage: $(GO) test -covermode=atomic -coverprofile=coverage.out ./... -## Run golangci-lint +## Run golangci-lint. lint: $(GOLANGCI_LINT) run ./... -## Format source code +## Format source code. fmt: $(GO) fmt ./... -## Tidy go.mod / go.sum +## Tidy go.mod / go.sum. tidy: $(GO) mod tidy -## Run all _examples to ensure they compile and run without panic +## Run all _examples to ensure they compile and run without panic. examples: $(GO) run ./_examples/stdout $(GO) run ./_examples/stderr >/dev/null 2>&1 || true $(GO) run ./_examples/file $(GO) run ./_examples/multi -## Show available targets +## Show available targets. help: @echo "Available targets:" @echo " make test - run tests" From c67ec385ffb86b04bb96d42bf1899f2fd7360bdb Mon Sep 17 00:00:00 2001 From: Alex Vakhov Date: Fri, 28 Nov 2025 16:28:53 +0500 Subject: [PATCH 8/8] review fixes: GoDoc comments + CI updates --- .github/workflows/testing.yml | 20 ++++++++++++++++++++ .golangci.yml => .golangci.yaml | 0 LICENCE | 6 +++++- Makefile | 12 ++---------- internal/outputs/outputs.go | 2 ++ internal/slog/buffer.go | 8 ++++++++ 6 files changed, 37 insertions(+), 11 deletions(-) rename .golangci.yml => .golangci.yaml (100%) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 351e9eb..e0f299d 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -29,3 +29,23 @@ jobs: - name: Run tests with race-detector run: make test-race + + golangci-lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/setup-go@v5 + - uses: actions/checkout@v5 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + continue-on-error: true + with: + # The first run is for GitHub Actions error format. + args: --config=.golangci.yaml + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + # The second run is for human-readable error format with a file name + # and a line number. + args: --out-${NO_FUTURE}format colored-line-number --config=.golangci.yaml diff --git a/.golangci.yml b/.golangci.yaml similarity index 100% rename from .golangci.yml rename to .golangci.yaml diff --git a/LICENCE b/LICENCE index 8f5ce5a..0e5bc0d 100644 --- a/LICENCE +++ b/LICENCE @@ -22,4 +22,8 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Portions of this software are derived from the Go standard library, +Copyright (c) The Go Authors, +and are used under the terms of the BSD-style license. \ No newline at end of file diff --git a/Makefile b/Makefile index 06b79ee..bfc4754 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ GO ?= go GOLANGCI_LINT ?= golangci-lint PKG := github.com/tarantool/go-tlog -.PHONY: all test test-race test-coverage lint fmt tidy examples help +.PHONY: all test test-race test-coverage lint fmt tidy help all: test @@ -21,7 +21,7 @@ test-coverage: ## Run golangci-lint. lint: - $(GOLANGCI_LINT) run ./... + $(GOLANGCI_LINT) run ./... --config=.golangci.yaml ## Format source code. fmt: @@ -31,13 +31,6 @@ fmt: tidy: $(GO) mod tidy -## Run all _examples to ensure they compile and run without panic. -examples: - $(GO) run ./_examples/stdout - $(GO) run ./_examples/stderr >/dev/null 2>&1 || true - $(GO) run ./_examples/file - $(GO) run ./_examples/multi - ## Show available targets. help: @echo "Available targets:" @@ -47,4 +40,3 @@ help: @echo " make lint - run golangci-lint" @echo " make fmt - format sources (gofmt)" @echo " make tidy - go mod tidy" - @echo " make examples - run all examples" diff --git a/internal/outputs/outputs.go b/internal/outputs/outputs.go index 21d18f1..7d65765 100644 --- a/internal/outputs/outputs.go +++ b/internal/outputs/outputs.go @@ -92,6 +92,8 @@ func multiClose(files []*os.File) error { return errors.Join(errs...) } +// Write writes p to all configured output destinations. +// It implements io.Writer and is used by slog handlers. func (o *Outputs) Write(p []byte) (int, error) { return o.w.Write(p) } diff --git a/internal/slog/buffer.go b/internal/slog/buffer.go index c428709..3ff5d81 100644 --- a/internal/slog/buffer.go +++ b/internal/slog/buffer.go @@ -25,6 +25,7 @@ func newBuffer() *buffer { return bufPool.Get().(*buffer) } +// Free releases the buffer back to the pool. func (b *buffer) Free() { // To reduce peak allocation, return only smaller buffers to the pool. const maxBufferSize = 16 << 10 @@ -34,33 +35,40 @@ func (b *buffer) Free() { } } +// Reset clears the buffer contents by setting its length to zero. func (b *buffer) Reset() { b.SetLen(0) } +// Write appends the provided bytes to the buffer. func (b *buffer) Write(p []byte) (int, error) { *b = append(*b, p...) return len(p), nil } +// WriteString appends the provided string to the buffer. func (b *buffer) WriteString(s string) (int, error) { *b = append(*b, s...) return len(s), nil } +// WriteByte appends a single byte to the buffer. func (b *buffer) WriteByte(c byte) error { *b = append(*b, c) return nil } +// String returns the contents of the buffer as a string. func (b *buffer) String() string { return string(*b) } +// Len returns the number of bytes currently stored in the buffer. func (b *buffer) Len() int { return len(*b) } +// SetLen reslices the buffer to the specified length. func (b *buffer) SetLen(n int) { *b = (*b)[:n] }