From 403ba4c42e57552f064122dc0939fe8790b7af17 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 7 Aug 2026 17:10:03 +0000 Subject: [PATCH] build: update golangci-lint to v2.12.2 with canonical config Add the canonical .golangci.yml (v2 schema, all linters enabled with a small documented disable list) and pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest, replacing the old v1.64.8 digest-only pin. Fix all findings surfaced by the v1 to v2 jump without changing any exported signatures or behavior: - add package and exported-symbol doc comments (revive) - rename unused handler parameters to underscore (revive) - check or explicitly discard error returns (errcheck, errchkjson) - wrap errors with %w instead of %v (err113) - use http.NewRequestWithContext instead of http.Post (noctx) - replace fmt.Println with fmt.Fprintln(os.Stdout, ...) (forbidigo) - name magic numbers as constants (mnd) - add explicit slog.LevelDebug case (exhaustive) - interface{} to any (modernize) - move tests to the simplelog_test package (testpackage) and add t.Parallel() (paralleltest) - move NewWebhookHandler above its methods (funcorder) - whitespace, line-length, and blank-line fixes (wsl_v5, whitespace, nlreturn, lll, embeddedstructfieldcheck) - nolint with justification for the intentional init/global design (gochecknoinits, gochecknoglobals) and interface-returning constructor (ireturn) --- .golangci.yml | 34 +++++++++++++++++++++++ Dockerfile | 4 +-- TODO.md | 4 +++ cmd/example/main.go | 8 ++++-- console_handler.go | 35 ++++++++++++++++++------ event.go | 4 +++ handler.go | 2 +- json_handler.go | 24 ++++++++++++----- json_handler_test.go | 9 +++++-- simplelog.go | 48 ++++++++++++++++++++++++++++----- simplelog_test.go | 3 ++- tools/relp_log_trial.go | 17 ++++++++++-- webhook_handler.go | 60 +++++++++++++++++++++++++++++------------ 13 files changed, 204 insertions(+), 48 deletions(-) create mode 100644 .golangci.yml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..26b1610 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +version: "2" + +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: all + disable: + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - depguard # Dependency allow/block lists + - godot # Requires comments to end with periods + - wsl # Deprecated, replaced by wsl_v5 + - wrapcheck # Too verbose for internal packages + - varnamelen # Short names like db, id are idiomatic Go + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile index 8d4123b..31c56bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Lint stage: format check + golangci-lint -# golangci-lint v1.64.8 (2025-02-18) -FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint +# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/TODO.md b/TODO.md index cbd9a8d..69b39fc 100644 --- a/TODO.md +++ b/TODO.md @@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig, # Completed Steps +* 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the + `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and + fixed all findings the v1→v2 jump surfaced without changing any + exported signatures or behavior * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with regression test; tagged 1.0.1 * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call diff --git a/cmd/example/main.go b/cmd/example/main.go index fc17316..4ecc657 100644 --- a/cmd/example/main.go +++ b/cmd/example/main.go @@ -1,3 +1,5 @@ +// Command example demonstrates logging through simplelog's default +// slog handler. package main import ( @@ -6,13 +8,15 @@ import ( _ "sneak.berlin/go/simplelog" ) -func main() { +// attemptNumber is the example login attempt count logged below. +const attemptNumber = 3 +func main() { // log structured data with slog as usual: slog.Info( "User login attempt", slog.String("user", "JohnDoe"), - slog.Int("attempt", 3), + slog.Int("attempt", attemptNumber), ) slog.Warn( "Configuration mismatch", diff --git a/console_handler.go b/console_handler.go index 87552c9..264b53e 100644 --- a/console_handler.go +++ b/console_handler.go @@ -4,26 +4,38 @@ import ( "context" "fmt" "log/slog" + "os" "runtime" "time" "github.com/fatih/color" ) +// callerSkipFrames is the number of stack frames between runtime.Caller +// and the slog call site that produced the record. +const callerSkipFrames = 4 + +// ConsoleHandler writes human-readable, colored log lines to stdout. type ConsoleHandler struct{} +// NewConsoleHandler returns a new ConsoleHandler. func NewConsoleHandler() *ConsoleHandler { return &ConsoleHandler{} } +// Handle writes the record to stdout as a colored, timestamped line +// including the caller file and line. func (c *ConsoleHandler) Handle( - ctx context.Context, + _ context.Context, record slog.Record, ) error { timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") - var colorFunc func(format string, a ...interface{}) string + + var colorFunc func(format string, a ...any) string switch record.Level { + case slog.LevelDebug: + colorFunc = color.New(color.FgWhite).SprintfFunc() case slog.LevelInfo: colorFunc = color.New(color.FgBlue).SprintfFunc() case slog.LevelWarn: @@ -35,12 +47,14 @@ func (c *ConsoleHandler) Handle( } // Get the caller information - _, file, line, ok := runtime.Caller(4) + _, file, line, ok := runtime.Caller(callerSkipFrames) if !ok { file = "???" line = 0 } - fmt.Println( + + _, _ = fmt.Fprintln( + os.Stdout, colorFunc( "%s [%s] %s:%d: %s", timestamp, @@ -50,20 +64,25 @@ func (c *ConsoleHandler) Handle( record.Message, ), ) + return nil } +// Enabled reports whether the handler processes records at the given +// level; it always returns true. func (c *ConsoleHandler) Enabled( - ctx context.Context, - level slog.Level, + _ context.Context, + _ slog.Level, ) bool { return true } -func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler { +// WithAttrs returns the handler unchanged; attributes are not rendered. +func (c *ConsoleHandler) WithAttrs(_ []slog.Attr) slog.Handler { return c } -func (c *ConsoleHandler) WithGroup(name string) slog.Handler { +// WithGroup returns the handler unchanged; groups are not rendered. +func (c *ConsoleHandler) WithGroup(_ string) slog.Handler { return c } diff --git a/event.go b/event.go index 271a48c..1139153 100644 --- a/event.go +++ b/event.go @@ -7,6 +7,8 @@ import ( "github.com/google/uuid" ) +// Event is a single structured log entry with a unique ID and +// timestamp. type Event struct { ID uuid.UUID `json:"id"` Timestamp time.Time `json:"timestamp"` @@ -15,6 +17,8 @@ type Event struct { Data json.RawMessage `json:"data"` } +// NewEvent returns an Event with a fresh ID and the current UTC +// timestamp. func NewEvent(level, message string, data json.RawMessage) Event { return Event{ ID: uuid.New(), diff --git a/handler.go b/handler.go index 6c8082d..85474ca 100644 --- a/handler.go +++ b/handler.go @@ -2,7 +2,7 @@ package simplelog import "log/slog" -// Handler defines the interface for different log outputs. +// ExtendedHandler defines the interface for different log outputs. type ExtendedHandler interface { slog.Handler } diff --git a/json_handler.go b/json_handler.go index 4c1c95b..d66c3d6 100644 --- a/json_handler.go +++ b/json_handler.go @@ -8,26 +8,38 @@ import ( "os" ) +// JSONHandler writes each log record to stdout as a JSON document. type JSONHandler struct{} +// NewJSONHandler returns a new JSONHandler. func NewJSONHandler() *JSONHandler { return &JSONHandler{} } -func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error { - jsonData, _ := json.Marshal(record) - fmt.Fprintln(os.Stdout, string(jsonData)) +// Handle marshals the record to JSON and writes it to stdout. +func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error { + jsonData, err := json.Marshal(record) + if err != nil { + return err + } + + _, _ = fmt.Fprintln(os.Stdout, string(jsonData)) + return nil } -func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool { +// Enabled reports whether the handler processes records at the given +// level; it always returns true. +func (j *JSONHandler) Enabled(_ context.Context, _ slog.Level) bool { return true } -func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler { +// WithAttrs returns the handler unchanged; attributes are not rendered. +func (j *JSONHandler) WithAttrs(_ []slog.Attr) slog.Handler { return j } -func (j *JSONHandler) WithGroup(name string) slog.Handler { +// WithGroup returns the handler unchanged; groups are not rendered. +func (j *JSONHandler) WithGroup(_ string) slog.Handler { return j } diff --git a/json_handler_test.go b/json_handler_test.go index 9c58cf5..783b240 100644 --- a/json_handler_test.go +++ b/json_handler_test.go @@ -1,22 +1,27 @@ -package simplelog +package simplelog_test import ( "log/slog" "testing" "time" + + "sneak.berlin/go/simplelog" ) // TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock // when the default slog handler routes log.Println back through slog. // On the unfixed code this test will hang (deadlock); with the fix it completes. func TestJSONHandlerDeadlock(t *testing.T) { - handler := NewJSONHandler() + t.Parallel() + + handler := simplelog.NewJSONHandler() // Set our handler as the default so log.Println routes through slog logger := slog.New(handler) slog.SetDefault(logger) done := make(chan struct{}) + go func() { // This call deadlocks on unfixed code because Handle() calls // log.Println() which re-enters slog → Handle() → log.Println() … diff --git a/simplelog.go b/simplelog.go index 08316bc..8fe77dc 100644 --- a/simplelog.go +++ b/simplelog.go @@ -1,3 +1,7 @@ +// Package simplelog installs a multiplexing slog handler as the process +// default on import. It logs human-readable colored output when stdout is +// a terminal, JSON otherwise, and can additionally POST each record to a +// webhook configured via the LOGGER_WEBHOOK_URL environment variable. package simplelog import ( @@ -13,23 +17,31 @@ import ( "github.com/mattn/go-isatty" ) +//nolint:gochecknoglobals // webhook destination is read once from the environment +var webhookURL = os.Getenv("LOGGER_WEBHOOK_URL") + +//nolint:gochecknoglobals // package-level default logger state is the package's design var ( - webhookURL = os.Getenv("LOGGER_WEBHOOK_URL") + ourCustomLogger *slog.Logger + ourCustomHandler slog.Handler ) -var ourCustomLogger *slog.Logger -var ourCustomHandler slog.Handler - +//nolint:gochecknoinits // installs itself as slog default on import by design func init() { ourCustomHandler = NewMultiplexHandler() ourCustomLogger = slog.New(ourCustomHandler) slog.SetDefault(ourCustomLogger) } +// MultiplexHandler fans each log record out to a set of underlying +// handlers. type MultiplexHandler struct { handlers []ExtendedHandler } +// NewMultiplexHandler returns a handler that writes colored console +// output when stdout is a terminal and JSON otherwise, plus an optional +// webhook handler when LOGGER_WEBHOOK_URL is set. func NewMultiplexHandler() slog.Handler { cl := &MultiplexHandler{} if isatty.IsTerminal(os.Stdout.Fd()) { @@ -37,52 +49,69 @@ func NewMultiplexHandler() slog.Handler { } else { cl.handlers = append(cl.handlers, NewJSONHandler()) } + if webhookURL != "" { handler, err := NewWebhookHandler(webhookURL) if err != nil { log.Fatalf("Failed to initialize Webhook handler: %v", err) } + cl.handlers = append(cl.handlers, handler) } + return cl } +// Handle forwards the record to every underlying handler, stopping at +// the first error. func (cl *MultiplexHandler) Handle( ctx context.Context, record slog.Record, ) error { for _, handler := range cl.handlers { - if err := handler.Handle(ctx, record); err != nil { + err := handler.Handle(ctx, record) + if err != nil { return err } } + return nil } +// Enabled reports whether the handler processes records at the given +// level; it always returns true. func (cl *MultiplexHandler) Enabled( - ctx context.Context, - level slog.Level, + _ context.Context, + _ slog.Level, ) bool { // send us all events return true } +// WithAttrs returns a new MultiplexHandler whose underlying handlers +// each carry the given attributes. func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler { newHandlers := make([]ExtendedHandler, len(cl.handlers)) for i, handler := range cl.handlers { newHandlers[i] = handler.WithAttrs(attrs) } + return &MultiplexHandler{handlers: newHandlers} } +// WithGroup returns a new MultiplexHandler whose underlying handlers +// each use the given group name. func (cl *MultiplexHandler) WithGroup(name string) slog.Handler { newHandlers := make([]ExtendedHandler, len(cl.handlers)) for i, handler := range cl.handlers { newHandlers[i] = handler.WithGroup(name) } + return &MultiplexHandler{handlers: newHandlers} } +// ExtendedEvent describes an Event augmented with caller file and line +// information. type ExtendedEvent interface { GetID() uuid.UUID GetTimestamp() time.Time @@ -95,6 +124,7 @@ type ExtendedEvent interface { type extendedEvent struct { Event + File string `json:"file"` Line int `json:"line"` } @@ -127,6 +157,10 @@ func (e extendedEvent) GetLine() int { return e.Line } +// NewExtendedEvent wraps baseEvent with the caller file and line it was +// logged from. +// +//nolint:ireturn // returning the interface is this constructor's public API func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent { return extendedEvent{ Event: baseEvent, diff --git a/simplelog_test.go b/simplelog_test.go index 8b97c39..a162ee3 100644 --- a/simplelog_test.go +++ b/simplelog_test.go @@ -1,8 +1,9 @@ -package simplelog +package simplelog_test import "testing" // TestCompile checks if the package compiles successfully. func TestCompile(t *testing.T) { + t.Parallel() // This test ensures that the simplelog package compiles without error. } diff --git a/tools/relp_log_trial.go b/tools/relp_log_trial.go index de140dd..5c11d32 100644 --- a/tools/relp_log_trial.go +++ b/tools/relp_log_trial.go @@ -1,3 +1,5 @@ +// Command relp_log_trial emits sample log messages through simplelog's +// default slog handler for manual testing. package main import ( @@ -6,11 +8,22 @@ import ( _ "sneak.berlin/go/simplelog" // Using underscore to only invoke init() ) +// examplePort is the sample database port logged below. +const examplePort = 5432 + func main() { // Send some test messages with structured data slog.Info("Starting the application", slog.String("status", "initialized")) - slog.Info("Attempting to connect to database", slog.String("host", "localhost"), slog.Int("port", 5432)) + slog.Info( + "Attempting to connect to database", + slog.String("host", "localhost"), + slog.Int("port", examplePort), + ) slog.Warn("Using default configuration", slog.String("configuration", "default")) - slog.Error("Failed to load module", slog.String("module", "finance"), slog.String("error", "module not found")) + slog.Error( + "Failed to load module", + slog.String("module", "finance"), + slog.String("error", "module not found"), + ) slog.Info("Shutting down the application", slog.String("status", "stopped")) } diff --git a/webhook_handler.go b/webhook_handler.go index 2c377aa..7213b6c 100644 --- a/webhook_handler.go +++ b/webhook_handler.go @@ -10,38 +10,64 @@ import ( "net/url" ) +// WebhookHandler POSTs each log record as JSON to a configured webhook +// URL. type WebhookHandler struct { webhookURL string } -func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool { - return true -} - -func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return w -} - -func (w *WebhookHandler) WithGroup(name string) slog.Handler { - return w -} - +// NewWebhookHandler returns a WebhookHandler that delivers records to +// the given URL, validating the URL first. func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) { - if _, err := url.ParseRequestURI(webhookURL); err != nil { - return nil, fmt.Errorf("invalid webhook URL: %v", err) + _, err := url.ParseRequestURI(webhookURL) + if err != nil { + return nil, fmt.Errorf("invalid webhook URL: %w", err) } + return &WebhookHandler{webhookURL: webhookURL}, nil } +// Enabled reports whether the handler processes records at the given +// level; it always returns true. +func (w *WebhookHandler) Enabled(_ context.Context, _ slog.Level) bool { + return true +} + +// WithAttrs returns the handler unchanged; attributes are not rendered. +func (w *WebhookHandler) WithAttrs(_ []slog.Attr) slog.Handler { + return w +} + +// WithGroup returns the handler unchanged; groups are not rendered. +func (w *WebhookHandler) WithGroup(_ string) slog.Handler { + return w +} + +// Handle marshals the record to JSON and POSTs it to the webhook URL. func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { jsonData, err := json.Marshal(record) if err != nil { - return fmt.Errorf("error marshaling event: %v", err) + return fmt.Errorf("error marshaling event: %w", err) } - response, err := http.Post(w.webhookURL, "application/json", bytes.NewBuffer(jsonData)) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + w.webhookURL, + bytes.NewReader(jsonData), + ) + if err != nil { + return fmt.Errorf("error creating webhook request: %w", err) + } + + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) if err != nil { return err } - defer response.Body.Close() + + defer func() { _ = response.Body.Close() }() + return nil } -- 2.49.1