From 7cf0d046e2ebb33392e829cff5dfc8c85fa6429e Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 5 Nov 2026 14:15:17 +0300 Subject: [PATCH 1/2] Add the slogger package A log/slog logger with a configurable format, a hot-reloadable log level and a custom writer. --- README.md | 1 + go.work | 1 + slogger/.golangci.yml | 350 ++++++++++++++++++++++++++++++++++++++++ slogger/README.md | 137 ++++++++++++++++ slogger/go.mod | 10 ++ slogger/go.sum | 6 + slogger/slogger.go | 111 +++++++++++++ slogger/slogger_test.go | 284 ++++++++++++++++++++++++++++++++ 8 files changed, 900 insertions(+) create mode 100644 slogger/.golangci.yml create mode 100644 slogger/README.md create mode 100644 slogger/go.mod create mode 100644 slogger/go.sum create mode 100644 slogger/slogger.go create mode 100644 slogger/slogger_test.go diff --git a/README.md b/README.md index f4f296b..2e9120f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ one of them does not pull anything else in. | [deferred](./deferred) | Helpers for the `defer` calls. | | [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | | [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | +| [slogger](./slogger) | A `log/slog` logger with a configurable format, a hot-reloadable level and a writer. | | [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | | [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. | | [time](./time) | A duration type that is serialized as a human-readable string. | diff --git a/go.work b/go.work index fe5d76b..6d8ef79 100644 --- a/go.work +++ b/go.work @@ -6,6 +6,7 @@ use ( ./deferred ./dispatcher ./protomap + ./slogger ./strcase ./sync ./time diff --git a/slogger/.golangci.yml b/slogger/.golangci.yml new file mode 100644 index 0000000..e9e3af8 --- /dev/null +++ b/slogger/.golangci.yml @@ -0,0 +1,350 @@ +version: "2" + +run: + timeout: 10m + +severity: + default: "critical" + +formatters: + enable: + - gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification. + - goimports # Check import statements are formatted according to the 'goimport' command. + + settings: + gofmt: + rewrite-rules: + - pattern: 'a[b:len(a)]' + replacement: 'a[b:]' + +linters: + default: none + enable: + - asasalint # Check for pass []any as any in variadic func(…any). + - asciicheck # Checks that all code identifiers does not have non-ASCII symbols in the name. + - bidichk # Checks for dangerous unicode character sequences. + - containedctx # Containedctx is a linter that detects struct contained context.Context field. + - contextcheck # Check whether the function uses a non-inherited context. + - copyloopvar # Copyloopvar is a linter detects places where loop variables are copied. + - depguard # Go linter that checks if package imports are in a list of acceptable packages. + - dupword # Checks for duplicate words in the source code. + - durationcheck # Check for two durations multiplied together. + - embeddedstructfieldcheck # Embedded types should be at the top of the field list of a struct, and there must be an empty line separating embedded fields from regular fields. + - errcheck # Errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases. + - errname # Checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error. + - errorlint # Errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. + - exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions. + - fatcontext # Detects nested contexts in loops and function literals. + - forcetypeassert # Find forced type assertions. + - funcorder # Checks the order of functions, methods, and constructors. + - gocheckcompilerdirectives # Checks that go compiler directive comments (//go:) are valid. + - gocognit # Computes and checks the cognitive complexity of functions. + - goconst # Finds repeated strings that could be replaced by a constant. + - gocritic # Provides diagnostics that check for bugs, performance and style issues. + - goheader # Checks if file header matches to pattern. + - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. + - gosec # Inspects source code for security problems. + - govet # Vet examines Go source code and reports suspicious constructs. + - iface # Detect the incorrect use of interfaces, helping developers avoid interface pollution. + - inamedparam # Reports interfaces with unnamed method parameters. + - intrange # Intrange is a linter to find places where for loops could make use of an integer range. + - iotamixing # Checks if iotas are being used in const blocks with other non-iota declarations. + - makezero # Finds slice declarations with non-zero initial length. + - mirror # Reports wrong mirror patterns of bytes/strings usage. + - mnd # An analyzer to detect magic numbers. + - modernize # A suite of analyzers that suggest simplifications to Go code, using modern language and library features. + - nakedret # Checks that functions with naked returns are not longer than a maximum size (can be zero). + - nilnil # Checks that there is no simultaneous return of nil error and an invalid value. + - nolintlint # Reports ill-formed or insufficient nolint directives. + - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. + - perfsprint # Checks that fmt.Sprintf can be replaced with a faster alternative. + - prealloc # Finds slice declarations that could potentially be pre-allocated. + - reassign # Checks that package variables are not reassigned. + - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. + - sloglint # Ensure consistent code style when using log/slog. + - staticcheck # It's a set of rules from staticcheck. + - testifylint # Checks usage of github.com/stretchr/testify. + - testpackage # Linter that makes you use a separate _test package. + - thelper # Thelper detects tests helpers which do not start with the t.Helper() method. + - tparallel # Tparallel detects inappropriate usage of t.Parallel() method in your Go test codes. + - unconvert # Remove unnecessary type conversions. + - unused # Checks Go code for unused constants, variables, functions and types. + - usestdlibvars # A linter that detect the possibility to use variables/constants from the Go standard library. + - usetesting # Reports uses of functions with replacement inside the testing package. + - wastedassign # Finds wasted assignment statements. + - whitespace # Checks for unnecessary newlines at the start and end of functions, if, for, etc. + + exclusions: + rules: + - text: 'Error return value of .((os\.)?std(out|err)\..*|.*print(f|ln)?). is not checked' + linters: [ errcheck ] + - path: (.+)_test\.go + linters: [ gocognit, goconst, mnd ] + - text: 'assigned to ctx' + linters: [ wastedassign ] + - text: 'ctx is never used' + linters: [ staticcheck ] + + paths: + - \\.git$ + - assets$ + - bench(mark)?s?$ + - bin$ + - build$ + - configs?$ + - deploy(ment)?s?$ + - docs$ + - githooks$ + - scripts$ + + generated: strict + + settings: + depguard: + rules: + main: + list-mode: lax + deny: + - pkg: 'math/rand$' + desc: 'use math/rand/v2' + - pkg: 'github.com/pkg/errors' + desc: 'Should be replaced by standard lib errors package' + + dupword: + comments-only: true + + embeddedstructfieldcheck: + forbid-mutex: true + + errcheck: + exclude-functions: + - encoding/json.Marshal + - encoding/json.MarshalIndent + + gocognit: + min-complexity: 10 + + goconst: + min-len: 2 + min-occurrences: 2 + + gocritic: + disable-all: true + enabled-checks: + ## DIAGNOSTIC + - appendAssign # Detects suspicious append result assignments. + - argOrder # Detects suspicious arguments order. + - badCall # Detects suspicious function calls. + - badCond # Detects suspicious condition expressions. + - badLock # Detects suspicious mutex lock/unlock operations. + - badRegexp # Detects suspicious regexp patterns. + - badSorting # Detects bad usage of sort package. + - badSyncOnceFunc # Detects bad usage of sync.OnceFunc. + - builtinShadowDecl # Detects top-level declarations that shadow the predeclared identifiers. + - caseOrder # Detects erroneous case order inside switch statements. + - codegenComment # Detects malformed ‘code generated’ file comments. + - commentedOutCode # Detects commented-out code inside function bodies. + - deferInLoop # Detects loops inside functions that use defer. + - deprecatedComment # Detects malformed ‘deprecated’ doc-comments. + - dupArg # Detects suspicious duplicated arguments. + - dupBranchBody # Detects duplicated branch bodies inside conditional statements. + - dupCase # Detects duplicated case clauses inside switch or select statements. + - dupOption # Detects duplicated option function arguments in variadic function calls. + - dupSubExpr # Detects suspicious duplicated sub-expressions. + - dynamicFmtString # Detects suspicious formatting strings usage. + - emptyDecl # Detects suspicious empty declarations blocks. + - evalOrder # Detects unwanted dependencies on the evaluation order. + - exitAfterDefer # Detects calls to exit/fatal inside functions that use defer. + - externalErrorReassign # Detects suspicious reassignment of error from another package. + - filepathJoin # Detects problems in filepath.Join() function calls. + - flagDeref # Detects immediate dereferencing of flag package pointers. + - flagName # Detects suspicious flag names. + - mapKey # Detects suspicious map literal keys. + - nilValReturn # Detects return statements those results evaluate to nil. + - offBy1 # Detects various off-by-one kind of errors. + - rangeAppendAll # Detects append all its data while range it. + - regexpPattern # Detects suspicious regexp patterns. + - returnAfterHttpError # Detects suspicious http.Error call without following return. + - sloppyLen # Detects usage of len when result is obvious or doesn’t make sense. + - sloppyReassign # Detects suspicious/confusing re-assignments. + - sloppyTypeAssert # Detects redundant type assertions. + - sortSlice # Detects suspicious sort.Slice calls. + - sprintfQuotedString # Detects “%s” formatting directives that can be replaced with %q. + - sqlQuery # Detects issue in Query() and Exec() calls. + - syncMapLoadAndDelete # Detects sync.Map load+delete operations that can be replaced with LoadAndDelete. + - truncateCmp # Detects potential truncation issues when comparing ints of different sizes. + - uncheckedInlineErr # Detects unchecked errors in if statements. + - unnecessaryDefer # Detects redundantly deferred calls. + - weakCond # Detects conditions that are unsafe due to not being exhaustive. + + ## STYLE + - assignOp # Detects assignments that can be simplified by using assignment operators. + - boolExprSimplify # Detects bool expressions that can be simplified. + - builtinShadow # Detects when predeclared identifiers are shadowed in assignments. + - captLocal # Detects capitalized names for local variables. + - commentFormatting # Detects comments with non-idiomatic formatting. + - commentedOutImport # Detects commented-out imports. + - defaultCaseOrder # Detects when default case in switch isn’t on 1st or last position. + - deferUnlambda # Detects deferred function literals that can be simplified. + - docStub # Detects comments that silence go lint complaints about doc-comment. + - dupImport # Detects multiple imports of the same package under different aliases. + - elseif # Detects else with nested if statement that can be replaced with else-if. + - emptyFallthrough # Detects fallthrough that can be avoided by using multi case values. + - emptyStringTest # Detects empty string checks that can be written more idiomatically. + - exposedSyncMutex # Detects exposed methods from sync.Mutex and sync.RWMutex. + - hexLiteral # Detects hex literals that have mixed case letter digits. + - httpNoBody # Detects nil usages in http.NewRequest calls, suggesting http.NoBody as an alternative. + - ifElseChain # Detects repeated if-else statements and suggests to replace them with switch statement. + - importShadow # Detects when imported package names shadowed in the assignments. + - initClause # Detects non-assignment statements inside if/switch init clause. + - methodExprCall # Detects method expression call that can be replaced with a method call. + - nestingReduce # Finds where nesting level could be reduced. + - newDeref # Detects immediate dereferencing of new expressions. + - octalLiteral # Detects old-style octal literals. + - paramTypeCombine # Detects if function parameters could be combined by type and suggest the way to do it. + - preferFilepathJoin # Detects concatenation with os.PathSeparator which can be replaced with filepath.Join. + - ptrToRefParam # Detects input and output parameters that have a type of pointer to referential type. + - redundantSprint # Detects redundant fmt.Sprint calls. + - regexpMust # Detects regexp.Compile* that can be replaced with regexp.MustCompile*. + - regexpSimplify # Detects regexp patterns that can be simplified. + - singleCaseSwitch # Detects switch statements that could be better written as if statement. + - stringConcatSimplify # Detects string concat operations that can be simplified. + - stringsCompare # Detects strings.Compare usage. + - switchTrue # Detects switch-over-bool statements that use explicit true tag value. + - timeExprSimplify # Detects manual conversion to milli- or microseconds. + - todoCommentWithoutDetail # Detects TODO comments without detail/assignee. + # tooManyResultsChecker # Detects function with too many results. + - typeAssertChain # Detects repeated type assertions and suggests to replace them with type switch statement. + - typeDefFirst # Detects method declarations preceding the type definition itself. + - typeSwitchVar # Detects type switches that can benefit from type guard clause with variable. + - typeUnparen # Detects unneeded parenthesis inside type expressions and suggests to remove them. + - underef # Detects dereference expressions that can be omitted. + - unlabelStmt # Detects redundant statement labels. + - unlambda # Detects function literals that can be simplified. + # unnamedResult # Detects unnamed results that may benefit from names. + # unnecessaryBlock # Detects unnecessary braced statement blocks. + - unslice # Detects slice expressions that can be simplified to sliced expression itself. + - valSwap # Detects value swapping code that are not using parallel assignment. + # whyNoLint # Ensures that //nolint comments include an explanation. + - wrapperFunc # Detects function calls that can be replaced with convenience wrappers. + - yodaStyleExpr # Detects Yoda style expressions and suggests to replace them. + + ## PERFORMANCE + - appendCombine # Detects append chains to the same slice that can be done in a single append call. + - equalFold # Detects unoptimal strings/bytes case-insensitive comparison. + - hugeParam # Detects params that incur excessive amount of copying. + - indexAlloc # Detects strings.Index calls that may cause unwanted allocs. + - preferDecodeRune # Detects expressions like []rune(s)[0] that may cause unwanted rune slice allocation. + - preferFprint # Detects fmt.Sprint(f/ln) calls which can be replaced with fmt.Fprint(f/ln). + - preferStringWriter # Detects w.Write or io.WriteString calls which can be replaced with w.WriteString. + - preferWriteByte # Detects WriteRune calls with rune literal argument that is single byte and reports to use WriteByte instead. + - rangeExprCopy # Detects expensive copies of for loop range expressions. + - rangeValCopy # Detects loops that copy big objects during each iteration. + - sliceClear # Detects slice clear loops, suggests an idiom that is recognized by the Go compiler. + - stringXbytes # Detects redundant conversions between string and []byte. + - zeroByteRepeat # Detects bytes.Repeat with 0 value. + + goheader: + template-path: ../.github/goheader.tmpl + + gomoddirectives: + replace-local: true + + gosec: + excludes: + # _ instead of err checks + - G104 + + govet: + enable-all: true + + iface: + enable: + - identical + - opaque + - unused + + inamedparam: + skip-single-param: true + + iotamixing: + report-individual: true + + nakedret: + max-func-lines: 10 + + nilnil: + detect-opposite: true + + nolintlint: + require-specific: true + + prealloc: + range-loops: true + + reassign: + patterns: + - '.*' + + revive: + severity: error + rules: + - name: atomic # Check for commonly mistaken usages of the sync/atomic package. + - name: confusing-naming # Methods or fields of struct that have names different only by capitalization could be confusing. + - name: confusing-results # Function or methods that return multiple, no named, values of the same type could induce error. + - name: constant-logical-expr # The rule spots logical expressions that evaluate always to the same value. + - name: context-as-argument # By convention, context.Context should be the first parameter of a function. This rule spots function declarations that do not follow the convention. + - name: context-keys-type # Basic types should not be used as a key in context.WithValue. + - name: deep-exit # Packages exposing functions that can stop program execution by exiting are hard to reuse. This rule looks for program exits in functions other than main() or init(). + - name: defer # This rule warns on some common mistakes when using defer statement. + arguments: + - - 'call-chain' + - 'loop' + - 'method-call' + - 'recover' + - 'immediate-recover' + - 'return' + - name: duplicated-imports # It is possible to unintentionally import the same package twice. This rule looks for packages that are imported two or more times. + - name: early-return # In GO it is idiomatic to minimize nesting statements, a typical example is to avoid if-then-else constructions. + - name: empty-block # Empty blocks make code less readable and could be a symptom of a bug or unfinished refactoring. + - name: empty-lines # Sometimes gofmt is not enough to enforce a common formatting of a code-base; this rule warns when there are heading or trailing newlines in code blocks. + - name: error-return # By convention, for the sake of readability, the errors should be last in the list of returned values by a function. + - name: error-strings # By convention, for better readability, error messages should not be capitalized or end with punctuation or a newline. + - name: errorf # It is possible to get a simpler program by replacing errors.New(fmt.Sprintf()) with fmt.Errorf(). This rule spots that kind of simplification opportunities. + - name: forbidden-call-in-wg-go # Warns on forbidden calls inside calls to wg.Go. + - name: identical-branches # An if-then-else conditional with identical implementations in both branches is an error. + - name: identical-ifelseif-branches # An if ... else if chain with identical branches makes maintenance harder and might be a source of bugs. Duplicated branches should be consolidated in one. + - name: identical-ifelseif-conditions # An if ... else if chain with identical conditions can lead to unreachable code and is a potential source of bugs while making the code harder to read and maintain. + - name: identical-switch-branches # A switch with identical branches makes maintenance harder and might be a source of bugs. Duplicated branches should be consolidated in one case clause. + - name: identical-switch-conditions # A switch statement with cases with the same condition can lead to unreachable code and is a potential source of bugs while making the code harder to read and maintain. + - name: if-return # Checking if an error is nil to just after return the error or nil is redundant. + - name: increment-decrement # By convention, for better readability, incrementing an integer variable by 1 is recommended to be done using the ++ operator. This rule spots expressions like i += 1 and i -= 1 and proposes to change them into i++ and i--. + - name: inefficient-map-lookup # This rule identifies code that iteratively searches for a key in a map. + - name: optimize-operands-order # Conditional expressions can be written to take advantage of short circuit evaluation and speed up its average evaluation time by forcing the evaluation of less time-consuming terms before more costly ones. This rule spots logical expressions where the order of evaluation of terms seems non optimal. + - name: range # This rule suggests a shorter way of writing ranges that do not use the second value. + - name: redefines-builtin-id # Constant names like false, true, nil, function names like append, make, and basic type names like bool, and byte are not reserved words of the language; therefore the can be redefined. Even if possible, redefining these built in names can lead to bugs very difficult to detect. + - name: redundant-build-tag # This rule warns about redundant build tag comments // +build when //go:build is present. gofmt in Go 1.17+ automatically adds the //go:build constraint, making the // +build comment unnecessary. + - name: string-of-int # Explicit type conversion string(i) where i has an integer type other than rune might behave not as expected by the developer (e.g. string(42) is not "42"). This rule spot that kind of suspicious conversions. + - name: unchecked-type-assertion # This rule checks whether a type assertion result is checked (the ok value), preventing unexpected panics. + arguments: + - accept-ignored-assertion-result: true + - name: use-any # Since Go 1.18, interface{} has an alias: any. This rule proposes to replace instances of interface{} with any. + - name: use-errors-new # This rules identifies calls to fmt.Errorf that can be safely replaced by, the more efficient, errors.New. + - name: use-fmt-print # This rule proposes to replace calls to built-in print and println with their equivalents from fmt standard package. + - name: use-waitgroup-go # Since Go 1.25 the sync package proposes the WaitGroup.Go method. This method is a shorter and safer replacement for the idiom wg.Add ... go { ... wg.Done ... }. + - name: useless-break # This rule warns on useless break statements in case clauses of switch and select statements. + - name: useless-fallthrough # This rule warns on useless fallthrough statements in case clauses of switch statements. A fallthrough is considered useless if it's the single statement of a case clause block. + - name: unnecessary-if # Identifies `if-else` statements that can be replaced by simpler statements. + - name: unnecessary-stmt # This rule suggests to remove redundant statements like a break at the end of a case block, for improving the code's readability. + - name: unreachable-code # This rule spots and proposes to remove unreachable code. + - name: waitgroup-by-value # Function parameters that are passed by value, are in fact a copy of the original argument. Passing a copy of a sync.WaitGroup is usually not what the developer wants to do. This rule warns when a sync.WaitGroup expected as a by-value parameter in a function or method. + + sloglint: + attr-only: true + context: "scope" + msg-style: "capitalized" + key-naming-case: "snake" + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/slogger/README.md b/slogger/README.md new file mode 100644 index 0000000..eaecb54 --- /dev/null +++ b/slogger/README.md @@ -0,0 +1,137 @@ +# slogger + +A `log/slog` logger with a configurable format, a hot-reloadable log level and a +custom writer. + +## Public API + +```go +type Options struct { + // Leveler returns the current log level. + Leveler func() string + // LevelReloadCond is the condition variable that notifies the logger about + // a log level change. + LevelReloadCond *sync.Cond + // Writer is an optional writer the logs are written to. + // If it is not set, the logs are written to os.Stdout. + Writer io.Writer + // Format is the log output format. Allowed values: "json" and "text". + Format string +} + +func New(ctx context.Context, opts Options) (*slog.Logger, error) +``` + +- `Leveler` — the current log level: `"DEBUG"`, `"INFO"`, `"WARN"` or `"ERROR"` + (case-insensitive, with an optional offset, e.g. `"INFO+2"`). +- `Format` — `"json"` or `"text"`; any other value is an error. +- `LevelReloadCond` — the condition variable that notifies the logger about a level + change. Without it the level is set once, at startup. +- `Writer` — the destination of the records; `os.Stdout` by default. + +## Usage + +```go +logger, err := slogger.New(ctx, slogger.Options{ + Leveler: func() string { return "INFO" }, + Format: "json", +}) +if err != nil { + return err +} + +logger.Info("Server started", slog.String("addr", ":8080")) +// {"time":"...","level":"INFO","msg":"Server started","addr":":8080"} +``` + +A custom writer replaces `os.Stdout`: + +```go +file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) +if err != nil { + return err +} +defer file.Close() + +logger, err := slogger.New(ctx, slogger.Options{ + Leveler: func() string { return "INFO" }, + Format: "text", + Writer: file, +}) +``` + +The level is applied by the handler through a `slog.LevelVar`: the records below it +are dropped before they are formatted. + +## Log level reload + +With a `*sync.Cond` in `LevelReloadCond` the logger re-reads the level on every +notification: + +```go +var ( + levelMu sync.Mutex + level = "INFO" + + cond = sync.NewCond(&sync.Mutex{}) +) + +func setLevel(l string) { + levelMu.Lock() + level = l + levelMu.Unlock() + + cond.L.Lock() + cond.Broadcast() + cond.L.Unlock() +} + +logger, err := slogger.New(ctx, slogger.Options{ + Leveler: func() string { + levelMu.Lock() + defer levelMu.Unlock() + + return level + }, + Format: "text", + LevelReloadCond: cond, +}) +``` + +Two rules: + +- the level is changed before the notification is sent, and the notification is sent + under the lock of the condition variable: the logger reads the level and waits + atomically under that lock, so a notification sent outside it can be missed; +- the `Leveler` is called while the logger holds the lock of the condition variable, + so it must not acquire that lock itself. Use a separate mutex for the level, as + above. + +The logger stops watching the level when the context passed to `New` is canceled. + +On a level change the logger writes a notification record: + +``` +time=... level=INFO msg="Log level changed from INFO to DEBUG" +``` + +It is written directly through the handler, bypassing the level check, so it is +written even when the new level is `WARN` or `ERROR`. + +If the level cannot be parsed, the error is written as an `ERROR` record and the +current level is kept: + +``` +time=... level=ERROR msg="Failed to parse log level while hot-reload update" error="slog: level string \"bogus\": unknown name" +``` + +## Errors + +`New` returns an error if the level returned by `Leveler` cannot be parsed +(`slog: level string "bogus": unknown name`) or if the format is unknown +(`unknown log format "xml"`). + +## Concurrency + +The logger is safe for concurrent use, as any `slog.Logger` is. The `Leveler` is +called from the watcher goroutine, so it must be safe for concurrent use as well. diff --git a/slogger/go.mod b/slogger/go.mod new file mode 100644 index 0000000..32e6d1b --- /dev/null +++ b/slogger/go.mod @@ -0,0 +1,10 @@ +module github.com/nbgrp/pkg/slogger + +go 1.25.0 + +require ( + github.com/stretchr/testify v1.12.1 + go.uber.org/goleak v1.3.0 +) + +require go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/slogger/go.sum b/slogger/go.sum new file mode 100644 index 0000000..8c1274c --- /dev/null +++ b/slogger/go.sum @@ -0,0 +1,6 @@ +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/slogger/slogger.go b/slogger/slogger.go new file mode 100644 index 0000000..eab4608 --- /dev/null +++ b/slogger/slogger.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: BSD-3-Clause + +package slogger + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "sync" + "time" +) + +// Options configures the logger. +type Options struct { + // Leveler returns the current log level. + Leveler func() string + // LevelReloadCond is the condition variable that notifies the logger about + // a log level change. + LevelReloadCond *sync.Cond + // Writer is an optional writer the logs are written to. + // If it is not set, the logs are written to [os.Stdout]. + Writer io.Writer + // Format is the log output format. Allowed values: "json" and "text". + Format string +} + +// New initializes and returns a [slog.Logger] or an error. +func New(ctx context.Context, opts Options) (*slog.Logger, error) { + level := &slog.LevelVar{} + err := level.UnmarshalText([]byte(opts.Leveler())) + if err != nil { + return nil, err + } + + var ( + handler slog.Handler + options = &slog.HandlerOptions{Level: level} + ) + + var writer io.Writer = os.Stdout + if opts.Writer != nil { + writer = opts.Writer + } + + logFormat := opts.Format + switch logFormat { + case "json": + handler = slog.NewJSONHandler(writer, options) + case "text": + handler = slog.NewTextHandler(writer, options) + default: + return nil, fmt.Errorf("unknown log format %q", logFormat) + } + + l := slog.New(handler) + if opts.LevelReloadCond != nil { + go watchLogLevel(ctx, l, opts.LevelReloadCond, level, opts) + } + + return l, nil +} + +// watchLogLevel reloads the log level on every notification of the condition +// variable until the context is canceled. +func watchLogLevel(ctx context.Context, logger *slog.Logger, cond *sync.Cond, currentLevel *slog.LevelVar, opts Options) { + // A [sync.Cond] has no context support, so the watcher has to be woken up + // from the outside when the context is canceled. + // + // The lock is held during the broadcast: the watcher registers itself as a + // waiter before it releases the lock, so a broadcast sent without the lock + // could be lost and leave the watcher sleeping on a canceled context. + stop := context.AfterFunc(ctx, func() { + cond.L.Lock() + defer cond.L.Unlock() + + cond.Broadcast() + }) + defer stop() + + cond.L.Lock() + defer cond.L.Unlock() + + for { + select { + case <-ctx.Done(): + return + default: + } + + var newLevel slog.Level + err := newLevel.UnmarshalText([]byte(opts.Leveler())) + + switch { + case err != nil: + logger.ErrorContext(ctx, "Failed to parse log level while hot-reload update", slog.Any("error", err)) + case newLevel != currentLevel.Level(): + oldLevel := currentLevel.Level().String() + currentLevel.Set(newLevel) + _ = logger.Handler().Handle(ctx, slog.NewRecord( + time.Now(), + slog.LevelInfo, + fmt.Sprintf("Log level changed from %s to %s", oldLevel, newLevel.String()), + 0, + )) + } + + cond.Wait() + } +} diff --git a/slogger/slogger_test.go b/slogger/slogger_test.go new file mode 100644 index 0000000..23e6ce7 --- /dev/null +++ b/slogger/slogger_test.go @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: BSD-3-Clause + +package slogger_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "sync" + "testing" + "time" + + . "github.com/nbgrp/pkg/slogger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func TestNew_Format(t *testing.T) { + t.Parallel() + + t.Run("json", func(t *testing.T) { + t.Parallel() + + buf := &safeBuffer{} + logger, err := New(context.Background(), Options{ + Leveler: func() string { return "INFO" }, + Format: "json", + Writer: buf, + }) + require.NoError(t, err) + + logger.Info("Hello", slog.String("user_id", "42")) + + record := map[string]any{} + require.NoError(t, json.Unmarshal([]byte(buf.String()), &record)) + assert.Equal(t, "INFO", record["level"]) + assert.Equal(t, "Hello", record["msg"]) + assert.Equal(t, "42", record["user_id"]) + }) + + t.Run("text", func(t *testing.T) { + t.Parallel() + + buf := &safeBuffer{} + logger, err := New(context.Background(), Options{ + Leveler: func() string { return "INFO" }, + Format: "text", + Writer: buf, + }) + require.NoError(t, err) + + logger.Info("Hello", slog.String("user_id", "42")) + + out := buf.String() + assert.Contains(t, out, "level=INFO") + assert.Contains(t, out, "msg=Hello") + assert.Contains(t, out, "user_id=42") + }) + + t.Run("unknown", func(t *testing.T) { + t.Parallel() + + _, err := New(context.Background(), Options{ + Leveler: func() string { return "INFO" }, + Format: "xml", + }) + require.EqualError(t, err, `unknown log format "xml"`) + }) + + t.Run("empty", func(t *testing.T) { + t.Parallel() + + _, err := New(context.Background(), Options{ + Leveler: func() string { return "INFO" }, + }) + require.EqualError(t, err, `unknown log format ""`) + }) +} + +func TestNew_Level(t *testing.T) { + t.Parallel() + + t.Run("filters the records", func(t *testing.T) { + t.Parallel() + + buf := &safeBuffer{} + logger, err := New(context.Background(), Options{ + Leveler: func() string { return "WARN" }, + Format: "text", + Writer: buf, + }) + require.NoError(t, err) + + logger.Info("Info message") + logger.Warn("Warn message") + logger.Error("Error message") + + out := buf.String() + assert.NotContains(t, out, "Info message") + assert.Contains(t, out, "Warn message") + assert.Contains(t, out, "Error message") + }) + + t.Run("rejects an invalid level", func(t *testing.T) { + t.Parallel() + + _, err := New(context.Background(), Options{ + Leveler: func() string { return "bogus" }, + Format: "text", + }) + require.ErrorContains(t, err, `slog: level string "bogus"`) + }) +} + +func TestNew_CustomWriter(t *testing.T) { + t.Parallel() + + buf := &safeBuffer{} + logger, err := New(context.Background(), Options{ + Leveler: func() string { return "INFO" }, + Format: "text", + Writer: buf, + }) + require.NoError(t, err) + + logger.Info("Custom writer") + + assert.Contains(t, buf.String(), "Custom writer") +} + +func TestNew_LevelReload(t *testing.T) { + t.Run("lowers the level", func(t *testing.T) { + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + r, cancel := newReloader(t, "INFO") + defer cancel() + + r.logger.Debug("Dropped message") + require.NotContains(t, r.buf.String(), "Dropped message") + + r.setLevel("DEBUG") + requireEventually(t, r.buf, "Log level changed from INFO to DEBUG") + + r.logger.Debug("Accepted message") + assert.Contains(t, r.buf.String(), "Accepted message") + }) + + t.Run("raises the level", func(t *testing.T) { + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + r, cancel := newReloader(t, "DEBUG") + defer cancel() + + r.logger.Debug("Accepted message") + require.Contains(t, r.buf.String(), "Accepted message") + + r.setLevel("ERROR") + // The notification is written through the handler, so it is not + // filtered out by the new level. + requireEventually(t, r.buf, "Log level changed from DEBUG to ERROR") + requireEventuallyDropped(t, r.logger, r.buf) + + r.logger.Error("Error message") + assert.Contains(t, r.buf.String(), "Error message") + }) + + t.Run("reports an invalid level", func(t *testing.T) { + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + r, cancel := newReloader(t, "INFO") + defer cancel() + + r.setLevel("bogus") + requireEventually(t, r.buf, "Failed to parse log level while hot-reload update") + + // The current level is kept. + r.logger.Info("Accepted message") + assert.Contains(t, r.buf.String(), "Accepted message") + }) +} + +// reloader is a logger with a hot-reloadable log level. +type reloader struct { + logger *slog.Logger + cond *sync.Cond + buf *safeBuffer + // level is protected by the condition variable lock: the watcher reads it + // while holding the lock, and the test writes it under the same lock. + level string +} + +// newReloader creates a logger with the given initial log level. The returned +// function cancels the context of the logger. +func newReloader(t *testing.T, level string) (*reloader, context.CancelFunc) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + + r := &reloader{ + cond: sync.NewCond(&sync.Mutex{}), + buf: &safeBuffer{}, + level: level, + } + + logger, err := New(ctx, Options{ + Leveler: func() string { return r.level }, + Format: "text", + LevelReloadCond: r.cond, + Writer: r.buf, + }) + require.NoError(t, err) + + r.logger = logger + + return r, cancel +} + +// setLevel changes the log level and notifies the logger about it. +func (r *reloader) setLevel(level string) { + r.cond.L.Lock() + defer r.cond.L.Unlock() + + r.level = level + r.cond.Broadcast() +} + +// safeBuffer is a buffer that is safe for concurrent use. +type safeBuffer struct { + buf bytes.Buffer + mu sync.Mutex +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + + return b.buf.String() +} + +// requireEventually waits until the buffer contains the given text. +func requireEventually(t *testing.T, buf *safeBuffer, want string) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(buf.String(), want) { + return + } + + time.Sleep(10 * time.Millisecond) + } + + require.Failf(t, "timeout", "the record %q has not been written, got %q", want, buf.String()) +} + +// requireEventuallyDropped waits until the logger stops writing the records. +func requireEventuallyDropped(t *testing.T, logger *slog.Logger, buf *safeBuffer) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for i := 0; time.Now().Before(deadline); i++ { + msg := fmt.Sprintf("Probe %d", i) + logger.Info(msg) + + if !strings.Contains(buf.String(), msg) { + return + } + + time.Sleep(10 * time.Millisecond) + } + + require.Fail(t, "timeout", "the log level has not been changed") +} From 9b9f1416cdc4a125b7defc87db3c2062f8e1affe Mon Sep 17 00:00:00 2001 From: Alexander Menshchikov Date: Thu, 5 Nov 2026 14:15:20 +0300 Subject: [PATCH 2/2] Defer the goroutine leak checks in the closer and sync tests --- closer/closer_test.go | 6 +++--- sync/once_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/closer/closer_test.go b/closer/closer_test.go index 29997c4..7f9df7b 100644 --- a/closer/closer_test.go +++ b/closer/closer_test.go @@ -21,7 +21,7 @@ func TestCloser(t *testing.T) { key := struct{ k string }{k: "testkey"} t.Run("global", func(t *testing.T) { - goleak.VerifyNone(t, goleak.IgnoreCurrent()) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) SetContext(context.WithValue(context.Background(), key, "testvalue")) @@ -49,7 +49,7 @@ func TestCloser(t *testing.T) { }) t.Run("with context cancel", func(t *testing.T) { - goleak.VerifyNone(t, goleak.IgnoreCurrent()) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) ctx, cancel := context.WithCancel(context.Background()) _, c := New(ctx, WithContextCancel()) @@ -89,7 +89,7 @@ func TestCloser(t *testing.T) { }) t.Run("with signals", func(t *testing.T) { - goleak.VerifyNone(t, goleak.IgnoreCurrent()) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) ctx := context.Background() _, c := New(ctx, WithSignals(syscall.SIGINT, syscall.SIGTERM)) diff --git a/sync/once_test.go b/sync/once_test.go index ff572b4..dc28eb6 100644 --- a/sync/once_test.go +++ b/sync/once_test.go @@ -15,7 +15,7 @@ import ( ) func TestSuccessfulOnce_Do(t *testing.T) { - goleak.VerifyNone(t, goleak.IgnoreCurrent()) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) // Arrange const runLimit = 40 // Всего запусков