Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 403ba4c42e |
34
.golangci.yml
Normal file
34
.golangci.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
4
TODO.md
4
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
4
event.go
4
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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() …
|
||||
|
||||
48
simplelog.go
48
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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user