Compare commits
5 Commits
1.0.1
...
golangci-v
| Author | SHA1 | Date | |
|---|---|---|---|
| 403ba4c42e | |||
| 6cb690bfb7 | |||
| 3bd6551c8e | |||
| 403d853d27 | |||
| 4abd40d8e2 |
12
.gitea/workflows/check.yml
Normal file
12
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- run: docker build .
|
||||||
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
|
||||||
53
Dockerfile
53
Dockerfile
@@ -1,39 +1,20 @@
|
|||||||
# First stage: Use the golangci-lint image to run the linter
|
# Lint stage: format check + golangci-lint
|
||||||
FROM golangci/golangci-lint:latest as lint
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
# Set the Current Working Directory inside the container
|
WORKDIR /src
|
||||||
WORKDIR /app
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
# Copy the go.mod file and the rest of the application code
|
|
||||||
COPY go.mod ./
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
# Run golangci-lint
|
# Test stage: run full test suite
|
||||||
RUN golangci-lint run
|
# golang 1.22.12 (2025-02-04)
|
||||||
|
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
|
||||||
RUN sh -c 'test -z "$(gofmt -l .)"'
|
# Depend on lint stage so both stages always run
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
# Second stage: Use the official Golang image to run tests
|
WORKDIR /src
|
||||||
FROM golang:1.22 as test
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
# Set the Current Working Directory inside the container
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy the go.mod file and the rest of the application code
|
|
||||||
COPY go.mod ./
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN make test
|
||||||
# Run tests
|
|
||||||
RUN go test -v ./...
|
|
||||||
|
|
||||||
# Final stage: Combine the linting and testing stages
|
|
||||||
FROM golang:1.22 as final
|
|
||||||
|
|
||||||
# Ensure that the linting stage succeeded
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=lint /app .
|
|
||||||
COPY --from=test /app .
|
|
||||||
|
|
||||||
# Set the final CMD to something minimal since we only needed to verify lint and tests during build
|
|
||||||
CMD ["echo", "Build and tests passed successfully!"]
|
|
||||||
|
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: test
|
.PHONY: test fmt fmt-check lint check docker hooks
|
||||||
|
|
||||||
default: test
|
default: check
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@go test -v ./...
|
@go test -v ./...
|
||||||
@@ -9,9 +9,20 @@ fmt:
|
|||||||
goimports -l -w .
|
goimports -l -w .
|
||||||
golangci-lint run --fix
|
golangci-lint run --fix
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; }
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
sh -c 'test -z "$$(gofmt -l .)"'
|
|
||||||
|
check: fmt-check lint test
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build --progress plain .
|
docker build --progress plain .
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@echo "Installing git hooks..."
|
||||||
|
@mkdir -p .git/hooks
|
||||||
|
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
|
||||||
|
@chmod +x .git/hooks/pre-commit
|
||||||
|
@echo "Pre-commit hook installed."
|
||||||
|
|||||||
57
TODO.md
Normal file
57
TODO.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Workflow
|
||||||
|
|
||||||
|
* branch (from `main`)
|
||||||
|
* do the work in Next Step
|
||||||
|
* move Next Step to the top of Completed Steps
|
||||||
|
* move the top item of Future Steps into Next Step
|
||||||
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
|
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
* push
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
1.0+
|
||||||
|
|
||||||
|
Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0
|
||||||
|
maintenance; the library is referenced by the Go styleguide.
|
||||||
|
|
||||||
|
# Next Step
|
||||||
|
|
||||||
|
Bring the Makefile up to policy in one commit: add fmt-check, check, and
|
||||||
|
hooks targets (test/lint/fmt/docker exist) and add the missing policy
|
||||||
|
files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
|
||||||
|
.dockerignore, and .gitea/workflows/check.yml running make check.
|
||||||
|
|
||||||
|
# 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
|
||||||
|
stack depth fix so log locations report correctly, example script,
|
||||||
|
removed non-building RELP code; tagged v1.0.0
|
||||||
|
* 2024-05-22: module path moved to sneak.berlin/go/simplelog
|
||||||
|
* 2024-05-14: initial library: slog-based console, JSON, webhook, and
|
||||||
|
RELP handlers, MultiplexHandler, caller file/line info, UTC ISO
|
||||||
|
timestamps, level-colored output
|
||||||
|
|
||||||
|
# Future Steps
|
||||||
|
|
||||||
|
* Rewrite the Dockerfile to run make check with sha256-pinned base
|
||||||
|
images (currently golangci/golangci-lint:latest and golang:1.22,
|
||||||
|
unpinned, duplicating Makefile logic instead of calling it)
|
||||||
|
* Restructure README.md into the standard sections: Description,
|
||||||
|
Getting Started, Rationale, Design, TODO, License, Author
|
||||||
|
* Delete the old plain TODO file once this TODO.md lands
|
||||||
|
* Add .aider.* to .gitignore and remove the stray aider artifacts from
|
||||||
|
the working tree
|
||||||
|
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
|
||||||
|
inconsistent)
|
||||||
|
* Fix RELP output to cache (from old TODO)
|
||||||
|
* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
|
||||||
|
2024-06-14 because it did not build (README planned feature)
|
||||||
|
* Add regex filtering for webhook logs (from old TODO)
|
||||||
|
* Better console output format (from old TODO)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// Command example demonstrates logging through simplelog's default
|
||||||
|
// slog handler.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,13 +8,15 @@ import (
|
|||||||
_ "sneak.berlin/go/simplelog"
|
_ "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:
|
// log structured data with slog as usual:
|
||||||
slog.Info(
|
slog.Info(
|
||||||
"User login attempt",
|
"User login attempt",
|
||||||
slog.String("user", "JohnDoe"),
|
slog.String("user", "JohnDoe"),
|
||||||
slog.Int("attempt", 3),
|
slog.Int("attempt", attemptNumber),
|
||||||
)
|
)
|
||||||
slog.Warn(
|
slog.Warn(
|
||||||
"Configuration mismatch",
|
"Configuration mismatch",
|
||||||
|
|||||||
@@ -4,26 +4,38 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"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{}
|
type ConsoleHandler struct{}
|
||||||
|
|
||||||
|
// NewConsoleHandler returns a new ConsoleHandler.
|
||||||
func NewConsoleHandler() *ConsoleHandler {
|
func NewConsoleHandler() *ConsoleHandler {
|
||||||
return &ConsoleHandler{}
|
return &ConsoleHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle writes the record to stdout as a colored, timestamped line
|
||||||
|
// including the caller file and line.
|
||||||
func (c *ConsoleHandler) Handle(
|
func (c *ConsoleHandler) Handle(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
record slog.Record,
|
record slog.Record,
|
||||||
) error {
|
) error {
|
||||||
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
|
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 {
|
switch record.Level {
|
||||||
|
case slog.LevelDebug:
|
||||||
|
colorFunc = color.New(color.FgWhite).SprintfFunc()
|
||||||
case slog.LevelInfo:
|
case slog.LevelInfo:
|
||||||
colorFunc = color.New(color.FgBlue).SprintfFunc()
|
colorFunc = color.New(color.FgBlue).SprintfFunc()
|
||||||
case slog.LevelWarn:
|
case slog.LevelWarn:
|
||||||
@@ -35,12 +47,14 @@ func (c *ConsoleHandler) Handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the caller information
|
// Get the caller information
|
||||||
_, file, line, ok := runtime.Caller(4)
|
_, file, line, ok := runtime.Caller(callerSkipFrames)
|
||||||
if !ok {
|
if !ok {
|
||||||
file = "???"
|
file = "???"
|
||||||
line = 0
|
line = 0
|
||||||
}
|
}
|
||||||
fmt.Println(
|
|
||||||
|
_, _ = fmt.Fprintln(
|
||||||
|
os.Stdout,
|
||||||
colorFunc(
|
colorFunc(
|
||||||
"%s [%s] %s:%d: %s",
|
"%s [%s] %s:%d: %s",
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -50,20 +64,25 @@ func (c *ConsoleHandler) Handle(
|
|||||||
record.Message,
|
record.Message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether the handler processes records at the given
|
||||||
|
// level; it always returns true.
|
||||||
func (c *ConsoleHandler) Enabled(
|
func (c *ConsoleHandler) Enabled(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
level slog.Level,
|
_ slog.Level,
|
||||||
) bool {
|
) bool {
|
||||||
return true
|
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
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
4
event.go
4
event.go
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event is a single structured log entry with a unique ID and
|
||||||
|
// timestamp.
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
@@ -15,6 +17,8 @@ type Event struct {
|
|||||||
Data json.RawMessage `json:"data"`
|
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 {
|
func NewEvent(level, message string, data json.RawMessage) Event {
|
||||||
return Event{
|
return Event{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package simplelog
|
|||||||
|
|
||||||
import "log/slog"
|
import "log/slog"
|
||||||
|
|
||||||
// Handler defines the interface for different log outputs.
|
// ExtendedHandler defines the interface for different log outputs.
|
||||||
type ExtendedHandler interface {
|
type ExtendedHandler interface {
|
||||||
slog.Handler
|
slog.Handler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,38 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// JSONHandler writes each log record to stdout as a JSON document.
|
||||||
type JSONHandler struct{}
|
type JSONHandler struct{}
|
||||||
|
|
||||||
|
// NewJSONHandler returns a new JSONHandler.
|
||||||
func NewJSONHandler() *JSONHandler {
|
func NewJSONHandler() *JSONHandler {
|
||||||
return &JSONHandler{}
|
return &JSONHandler{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
|
// Handle marshals the record to JSON and writes it to stdout.
|
||||||
jsonData, _ := json.Marshal(record)
|
func (j *JSONHandler) Handle(_ context.Context, record slog.Record) error {
|
||||||
fmt.Fprintln(os.Stdout, string(jsonData))
|
jsonData, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(os.Stdout, string(jsonData))
|
||||||
|
|
||||||
return nil
|
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
|
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
|
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
|
return j
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
package simplelog
|
package simplelog_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplelog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
|
// TestJSONHandlerDeadlock verifies that JSONHandler.Handle does not deadlock
|
||||||
// when the default slog handler routes log.Println back through slog.
|
// 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.
|
// On the unfixed code this test will hang (deadlock); with the fix it completes.
|
||||||
func TestJSONHandlerDeadlock(t *testing.T) {
|
func TestJSONHandlerDeadlock(t *testing.T) {
|
||||||
handler := NewJSONHandler()
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := simplelog.NewJSONHandler()
|
||||||
|
|
||||||
// Set our handler as the default so log.Println routes through slog
|
// Set our handler as the default so log.Println routes through slog
|
||||||
logger := slog.New(handler)
|
logger := slog.New(handler)
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// This call deadlocks on unfixed code because Handle() calls
|
// This call deadlocks on unfixed code because Handle() calls
|
||||||
// log.Println() which re-enters slog → Handle() → log.Println() …
|
// 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
|
package simplelog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,23 +17,31 @@ import (
|
|||||||
"github.com/mattn/go-isatty"
|
"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 (
|
var (
|
||||||
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
|
ourCustomLogger *slog.Logger
|
||||||
|
ourCustomHandler slog.Handler
|
||||||
)
|
)
|
||||||
|
|
||||||
var ourCustomLogger *slog.Logger
|
//nolint:gochecknoinits // installs itself as slog default on import by design
|
||||||
var ourCustomHandler slog.Handler
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
ourCustomHandler = NewMultiplexHandler()
|
ourCustomHandler = NewMultiplexHandler()
|
||||||
ourCustomLogger = slog.New(ourCustomHandler)
|
ourCustomLogger = slog.New(ourCustomHandler)
|
||||||
slog.SetDefault(ourCustomLogger)
|
slog.SetDefault(ourCustomLogger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MultiplexHandler fans each log record out to a set of underlying
|
||||||
|
// handlers.
|
||||||
type MultiplexHandler struct {
|
type MultiplexHandler struct {
|
||||||
handlers []ExtendedHandler
|
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 {
|
func NewMultiplexHandler() slog.Handler {
|
||||||
cl := &MultiplexHandler{}
|
cl := &MultiplexHandler{}
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) {
|
if isatty.IsTerminal(os.Stdout.Fd()) {
|
||||||
@@ -37,52 +49,69 @@ func NewMultiplexHandler() slog.Handler {
|
|||||||
} else {
|
} else {
|
||||||
cl.handlers = append(cl.handlers, NewJSONHandler())
|
cl.handlers = append(cl.handlers, NewJSONHandler())
|
||||||
}
|
}
|
||||||
|
|
||||||
if webhookURL != "" {
|
if webhookURL != "" {
|
||||||
handler, err := NewWebhookHandler(webhookURL)
|
handler, err := NewWebhookHandler(webhookURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize Webhook handler: %v", err)
|
log.Fatalf("Failed to initialize Webhook handler: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cl.handlers = append(cl.handlers, handler)
|
cl.handlers = append(cl.handlers, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cl
|
return cl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle forwards the record to every underlying handler, stopping at
|
||||||
|
// the first error.
|
||||||
func (cl *MultiplexHandler) Handle(
|
func (cl *MultiplexHandler) Handle(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
record slog.Record,
|
record slog.Record,
|
||||||
) error {
|
) error {
|
||||||
for _, handler := range cl.handlers {
|
for _, handler := range cl.handlers {
|
||||||
if err := handler.Handle(ctx, record); err != nil {
|
err := handler.Handle(ctx, record)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether the handler processes records at the given
|
||||||
|
// level; it always returns true.
|
||||||
func (cl *MultiplexHandler) Enabled(
|
func (cl *MultiplexHandler) Enabled(
|
||||||
ctx context.Context,
|
_ context.Context,
|
||||||
level slog.Level,
|
_ slog.Level,
|
||||||
) bool {
|
) bool {
|
||||||
// send us all events
|
// send us all events
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAttrs returns a new MultiplexHandler whose underlying handlers
|
||||||
|
// each carry the given attributes.
|
||||||
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
func (cl *MultiplexHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
newHandlers := make([]ExtendedHandler, len(cl.handlers))
|
newHandlers := make([]ExtendedHandler, len(cl.handlers))
|
||||||
for i, handler := range cl.handlers {
|
for i, handler := range cl.handlers {
|
||||||
newHandlers[i] = handler.WithAttrs(attrs)
|
newHandlers[i] = handler.WithAttrs(attrs)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MultiplexHandler{handlers: newHandlers}
|
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 {
|
func (cl *MultiplexHandler) WithGroup(name string) slog.Handler {
|
||||||
newHandlers := make([]ExtendedHandler, len(cl.handlers))
|
newHandlers := make([]ExtendedHandler, len(cl.handlers))
|
||||||
for i, handler := range cl.handlers {
|
for i, handler := range cl.handlers {
|
||||||
newHandlers[i] = handler.WithGroup(name)
|
newHandlers[i] = handler.WithGroup(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MultiplexHandler{handlers: newHandlers}
|
return &MultiplexHandler{handlers: newHandlers}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtendedEvent describes an Event augmented with caller file and line
|
||||||
|
// information.
|
||||||
type ExtendedEvent interface {
|
type ExtendedEvent interface {
|
||||||
GetID() uuid.UUID
|
GetID() uuid.UUID
|
||||||
GetTimestamp() time.Time
|
GetTimestamp() time.Time
|
||||||
@@ -95,6 +124,7 @@ type ExtendedEvent interface {
|
|||||||
|
|
||||||
type extendedEvent struct {
|
type extendedEvent struct {
|
||||||
Event
|
Event
|
||||||
|
|
||||||
File string `json:"file"`
|
File string `json:"file"`
|
||||||
Line int `json:"line"`
|
Line int `json:"line"`
|
||||||
}
|
}
|
||||||
@@ -127,6 +157,10 @@ func (e extendedEvent) GetLine() int {
|
|||||||
return e.Line
|
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 {
|
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
|
||||||
return extendedEvent{
|
return extendedEvent{
|
||||||
Event: baseEvent,
|
Event: baseEvent,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package simplelog
|
package simplelog_test
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// TestCompile checks if the package compiles successfully.
|
// TestCompile checks if the package compiles successfully.
|
||||||
func TestCompile(t *testing.T) {
|
func TestCompile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
// This test ensures that the simplelog package compiles without error.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,11 +8,22 @@ import (
|
|||||||
_ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
|
_ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// examplePort is the sample database port logged below.
|
||||||
|
const examplePort = 5432
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Send some test messages with structured data
|
// Send some test messages with structured data
|
||||||
slog.Info("Starting the application", slog.String("status", "initialized"))
|
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.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"))
|
slog.Info("Shutting down the application", slog.String("status", "stopped"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,38 +10,64 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// WebhookHandler POSTs each log record as JSON to a configured webhook
|
||||||
|
// URL.
|
||||||
type WebhookHandler struct {
|
type WebhookHandler struct {
|
||||||
webhookURL string
|
webhookURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
// NewWebhookHandler returns a WebhookHandler that delivers records to
|
||||||
return true
|
// the given URL, validating the URL first.
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
||||||
if _, err := url.ParseRequestURI(webhookURL); err != nil {
|
_, err := url.ParseRequestURI(webhookURL)
|
||||||
return nil, fmt.Errorf("invalid webhook URL: %v", err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid webhook URL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &WebhookHandler{webhookURL: webhookURL}, nil
|
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 {
|
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||||
jsonData, err := json.Marshal(record)
|
jsonData, err := json.Marshal(record)
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
|
||||||
|
defer func() { _ = response.Body.Close() }()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user