build: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 29s
check / check (pull_request) Successful in 32s

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)
This commit is contained in:
2026-08-07 17:10:03 +00:00
parent 6cb690bfb7
commit 403ba4c42e
13 changed files with 204 additions and 48 deletions

34
.golangci.yml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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(),

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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() …

View File

@@ -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,

View File

@@ -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.
}

View File

@@ -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"))
}

View File

@@ -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
}