Compare commits

30 Commits

Author SHA1 Message Date
ac3031a547 Add vendored REPO_POLICIES.md from prompts repo
All checks were successful
check / check (push) Successful in 21s
2026-07-07 01:55:33 +02:00
5cb4f827d2 Adopt scripts-to-rule-them-all: script/ entrypoints, Makefile shims 2026-07-07 01:54:40 +02:00
6cb690bfb7 Add standard Workflow section to TODO.md
All checks were successful
check / check (push) Successful in 23s
2026-07-06 21:06:43 +02:00
3bd6551c8e Merge branch 'TODO'
All checks were successful
check / check (push) Successful in 46s
2026-07-06 20:51:17 +02:00
403d853d27 Add TODO.md 2026-07-06 20:35:50 +02:00
4abd40d8e2 fix: split Dockerfile with pinned images and add CI workflow (#14)
## Summary

Rewrites the Dockerfile to use sha256-pinned images and proper multi-stage build structure. Adds missing Makefile targets and a Gitea CI workflow.

## Changes

### Dockerfile
- **Lint stage**: `golangci/golangci-lint` v1.64.8 pinned by sha256 — runs `make fmt-check` + `make lint`
- **Test stage**: `golang` 1.22.12 pinned by sha256 — runs `make test` with dependency on lint stage
- Removed redundant final stage (this is a library with no binary to build)
- Both images pinned by digest with version+date comments

### Makefile
- Added `fmt-check` target: verifies `gofmt` compliance without modifying files
- Added `check` target: runs `fmt-check`, `lint`, `test` in sequence
- Added `hooks` target: installs a pre-commit hook that runs `make check`
- Separated `gofmt` check from `lint` target (was previously bundled)
- Changed default target from `test` to `check`

### CI
- Added `.gitea/workflows/check.yml`: runs `docker build .` on push to main and on PRs

## Verification

`docker build --progress plain .` passes — all stages complete successfully.

closes #9

<!-- session: agent:sdlc-manager:subagent:fffa0a5a-5127-4489-a2e0-314c5eaaed68 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #14
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-02 21:06:53 +01:00
9121da9aae Merge pull request 'fix: JSONHandler deadlock from recursive log.Println (closes #3)' (#4) from clawbot/simplelog:fix/json-handler-deadlock into main
Reviewed-on: #4
2026-02-08 18:29:55 +01:00
74ce052b77 Merge branch 'main' into fix/json-handler-deadlock 2026-02-08 18:29:12 +01:00
1eef38a5fa Merge pull request 'test: add deadlock regression test for JSONHandler (issue #3)' (#7) from clawbot/simplelog:test/jsonhandler-deadlock into main
Reviewed-on: #7
2026-02-08 18:27:15 +01:00
97a82e9b2c test: add deadlock regression test for JSONHandler
Reproduces issue #3 — JSONHandler.Handle() calling log.Println() causes
a deadlock when slog.SetDefault redirects log output back through slog.

This test hangs/fails on main and should pass once #4 is merged.
2026-02-08 09:21:08 -08:00
user
869b7ca4c3 fix: replace log.Println with fmt.Fprintln in JSONHandler to prevent deadlock 2026-02-08 09:15:17 -08:00
31c9ed52cb preparing for 1.0 2024-06-14 05:53:22 -07:00
28d0d041b0 lint and check fmt on docker build 2024-06-14 05:47:35 -07:00
278cb73053 fmt 2024-06-14 05:47:29 -07:00
ea0c84547f add example script 2024-06-14 05:39:08 -07:00
a852d938e7 ignore binary 2024-06-14 05:39:03 -07:00
a660203e8f fix call stack depth bug to show log location correctly 2024-06-14 05:38:29 -07:00
000fe293ee remove relp stuff for now, was not building 2024-06-14 05:34:05 -07:00
d8f35dd031 closer to ready 2024-06-10 02:39:01 -07:00
60cb410c32 update module path 2024-05-22 14:53:57 -07:00
29c31d44f8 latest 2024-05-22 14:52:20 -07:00
7e0266aefc aider: Rename CustomHandler to MultiplexHandler.
# Aider chat conversation:

USER: rename CustomHandler to MultiplexHandler

ASSISTANT: Here are the necessary changes to rename `CustomHandler` to `MultiplexHandler`:

simplelog.go
```go
<<<<<<< SEARCH
	ourCustomHandler = NewCustomHandler()
=======
	ourCustomHandler = NewMultiplexHandler()
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
type CustomHandler struct {
=======
type MultiplexHandler struct {
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
func NewCustomHandler() slog.Handler {
	cl := &CustomHandler{}
=======
func NewMultiplexHandler() slog.Handler {
	cl := &MultiplexHandler{}
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
func (cl *CustomHandler) Handle(ctx context.Context, record slog.Record) error {
=======
func (cl *MultiplexHandler) Handle(ctx context.Context, record slog.Record) error {
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
func (cl *CustomHandler) Enabled(ctx context.Context, level slog.Level) bool {
=======
func (cl *MultiplexHandler) Enabled(ctx context.Context, level slog.Level) bool {
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
func (cl *CustomHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	newHandlers := make([]ExtendedHandler, len(cl.handlers))
	for i, handler := range cl.handlers {
		newHandlers[i] = handler.WithAttrs(attrs)
	}
	return &CustomHandler{handlers: newHandlers}
}

func (cl *CustomHandler) WithGroup(name string) slog.Handler {
	newHandlers := make([]ExtendedHandler, len(cl.handlers))
	for i, handler := range cl.handlers {
		newHandlers[i] = handler.WithGroup(name)
	}
	return &CustomHandler{handlers: newHandlers}
=======
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}
}

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}
>>>>>>> REPLACE
```
2024-05-14 05:26:56 -07:00
66aae3d758 aider: Update NewExtendedEvent to accept base event as an argument and populate additional fields.
# Aider chat conversation:

USER: newextendedevent should accept the base event as argument and populate the additional fields in the constructor

ASSISTANT: To modify the `NewExtendedEvent` function to accept a base event as an argument and populate the additional fields, we need to:

1. Update the `NewExtendedEvent` function in `simplelog.go`.

Here is the *SEARCH/REPLACE* block:

simplelog.go
```go
<<<<<<< SEARCH
func NewExtendedEvent(level, message string, data json.RawMessage, file string, line int) ExtendedEvent {
	return extendedEvent{
		Event: NewEvent(level, message, data),
		File:  file,
		Line:  line,
	}
}
=======
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
	return extendedEvent{
		Event: baseEvent,
		File:  file,
		Line:  line,
	}
}
>>>>>>> REPLACE
```
2024-05-14 05:23:52 -07:00
4bc389781a aider: Moved the ExtendedEvent type and interface to simplelog.go.
# Aider chat conversation:

USER: the extendedevent type and interface should be in simplelog.go not the relp_handler.go because it's general to the simplelog package and not specific to the relp extendedhandler.

ASSISTANT: To move the `ExtendedEvent` type and interface to `simplelog.go`, we need to:

1. Remove the `ExtendedEvent` type and interface from `event.go`.
2. Add the `ExtendedEvent` type and interface to `simplelog.go`.

Here are the *SEARCH/REPLACE* blocks:

event.go
```go
<<<<<<< SEARCH
type ExtendedEvent interface {
	GetID() uuid.UUID
	GetTimestamp() time.Time
	GetLevel() string
	GetMessage() string
	GetData() json.RawMessage
	GetFile() string
	GetLine() int
}

type extendedEvent struct {
	Event
	File string `json:"file"`
	Line int    `json:"line"`
}

func (e extendedEvent) GetID() uuid.UUID {
	return e.ID
}

func (e extendedEvent) GetTimestamp() time.Time {
	return e.Timestamp
}

func (e extendedEvent) GetLevel() string {
	return e.Level
}

func (e extendedEvent) GetMessage() string {
	return e.Message
}

func (e extendedEvent) GetData() json.RawMessage {
	return e.Data
}

func (e extendedEvent) GetFile() string {
	return e.File
}

func (e extendedEvent) GetLine() int {
	return e.Line
}

func NewExtendedEvent(level, message string, data json.RawMessage, file string, line int) ExtendedEvent {
	return extendedEvent{
		Event: NewEvent(level, message, data),
		File:  file,
		Line:  line,
	}
}
=======
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
=======
type ExtendedEvent interface {
	GetID() uuid.UUID
	GetTimestamp() time.Time
	GetLevel() string
	GetMessage() string
	GetData() json.RawMessage
	GetFile() string
	GetLine() int
}

type extendedEvent struct {
	Event
	File string `json:"file"`
	Line int    `json:"line"`
}

func (e extendedEvent) GetID() uuid.UUID {
	return e.ID
}

func (e extendedEvent) GetTimestamp() time.Time {
	return e.Timestamp
}

func (e extendedEvent) GetLevel() string {
	return e.Level
}

func (e extendedEvent) GetMessage() string {
	return e.Message
}

func (e extendedEvent) GetData() json.RawMessage {
	return e.Data
}

func (e extendedEvent) GetFile() string {
	return e.File
}

func (e extendedEvent) GetLine() int {
	return e.Line
}

func NewExtendedEvent(level, message string, data json.RawMessage, file string, line int) ExtendedEvent {
	return extendedEvent{
		Event: NewEvent(level, message, data),
		File:  file,
		Line:  line,
	}
}
>>>>>>> REPLACE
```
2024-05-14 05:22:26 -07:00
3638c0c4df aider: Add file and line number information to ExtendedEvent in CustomHandler.
# Aider chat conversation:

USER: create an extended type called ExtendedEvent which conforms to Event.  in the custom handler, attach the file and line number information from the caller to the ExtendedEvent.  that way all of the extendedhandlers can access it without using the runtime module.  add getters to the ExtendedEvent interface and type.

ASSISTANT: To create an `ExtendedEvent` type that conforms to `Event` and includes file and line number information, we need to:

1. Define the `ExtendedEvent` type and interface in `event.go`.
2. Update the `CustomHandler` in `simplelog.go` to attach file and line number information to the `ExtendedEvent`.
3. Add getters to the `ExtendedEvent` interface and type.

Here are the *SEARCH/REPLACE* blocks:

event.go
```go
<<<<<<< SEARCH
}
=======
}

type ExtendedEvent interface {
	GetID() uuid.UUID
	GetTimestamp() time.Time
	GetLevel() string
	GetMessage() string
	GetData() json.RawMessage
	GetFile() string
	GetLine() int
}

type extendedEvent struct {
	Event
	File string `json:"file"`
	Line int    `json:"line"`
}

func (e extendedEvent) GetID() uuid.UUID {
	return e.ID
}

func (e extendedEvent) GetTimestamp() time.Time {
	return e.Timestamp
}

func (e extendedEvent) GetLevel() string {
	return e.Level
}

func (e extendedEvent) GetMessage() string {
	return e.Message
}

func (e extendedEvent) GetData() json.RawMessage {
	return e.Data
}

func (e extendedEvent) GetFile() string {
	return e.File
}

func (e extendedEvent) GetLine() int {
	return e.Line
}

func NewExtendedEvent(level, message string, data json.RawMessage, file string, line int) ExtendedEvent {
	return extendedEvent{
		Event: NewEvent(level, message, data),
		File:  file,
		Line:  line,
	}
}
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
	"log"
=======
	"log"
	"runtime"
>>>>>>> REPLACE
```

simplelog.go
```go
<<<<<<< SEARCH
	event := NewEvent(record.Level.String(), record.Message, jsonData)
	select {
	case r.ch <- event:
		return nil // Successfully sent event to channel
	default:
		return fmt.Errorf("failed to log event: channel is full")
	}
=======
	// Get the caller information
	_, file, line, ok := runtime.Caller(5)
	if !ok {
		file = "???"
		line = 0
	}

	event := NewExtendedEvent(record.Level.String(), record.Message, jsonData, file, line)
	for _, handler := range cl.handlers {
		if err := handler.Handle(ctx, event); err != nil {
			return err
		}
	}
	return nil
>>>>>>> REPLACE
```
2024-05-14 05:20:44 -07:00
2c3d9a94fa Refactored CustomHandler to implement slog.Handler interface and updated initialization in init function. 2024-05-14 05:20:43 -07:00
500b4b0a96 aider: Include filename and line number in console logger output by using runtime.Caller to get the caller information.
# Aider chat conversation:

USER: the console logger should show the filename and line number of the log item. type slog.Record has no field or method Source.

ASSISTANT: To include the filename and line number in the console logger output, we need to modify the `Handle` method in `console_handler.go`. Since `slog.Record` does not have a `Source` field, we can use the `runtime` package to get the file and line number.

Here are the *SEARCH/REPLACE* blocks:

console_handler.go
```go
<<<<<<< SEARCH
	"time"
=======
	"runtime"
	"time"
>>>>>>> REPLACE
```

console_handler.go
```go
<<<<<<< SEARCH
	// Extract the source file and line number
	source := record.Source
	fmt.Println(colorFunc("%s [%s] %s:%d: %s", timestamp, record.Level, source.File, source.Line, record.Message))
=======
	// Get the caller information
	_, file, line, ok := runtime.Caller(5)
	if !ok {
		file = "???"
		line = 0
	}
	fmt.Println(colorFunc("%s [%s] %s:%d: %s", timestamp, record.Level, file, line, record.Message))
>>>>>>> REPLACE
```
2024-05-14 05:17:29 -07:00
3a94b5e22a aider: Include filename and line number in console logger output.
# Aider chat conversation:

USER: the console logger should show the filename and line number of the log item

ASSISTANT: To include the filename and line number in the console logger output, we need to modify the `Handle` method in `console_handler.go` to extract and display this information.

Here is the *SEARCH/REPLACE* block:

console_handler.go
```go
<<<<<<< SEARCH
	fmt.Println(colorFunc("%s [%s] %s", timestamp, record.Level, record.Message))
=======
	// Extract the source file and line number
	source := record.Source
	fmt.Println(colorFunc("%s [%s] %s:%d: %s", timestamp, record.Level, source.File, source.Line, record.Message))
>>>>>>> REPLACE
```
2024-05-14 05:17:08 -07:00
e94fd9e1ee aider: Remove reference to record.Source and update logging format in console_handler.go.
# Aider chat conversation:

USER: ../console_handler.go:34:19: record.Source undefined (type slog.Record has no field or method Source)

ASSISTANT: To fix the issue with `record.Source` being undefined, we need to update the `console_handler.go` file to remove the reference to `record.Source`. Instead, we can log the message without the source file and line number.

Here are the *SEARCH/REPLACE* blocks:

console_handler.go
```python
<<<<<<< SEARCH
	// Extract the source file and line number
	source := record.Source
	fmt.Println(colorFunc("%s [%s] %s:%d: %s", timestamp, record.Level, source.File, source.Line, record.Message))
=======
	fmt.Println(colorFunc("%s [%s] %s", timestamp, record.Level, record.Message))
>>>>>>> REPLACE
```
2024-05-14 05:16:56 -07:00
fdcfbb02ac aider: Include filename and line number in console logger output.
# Aider chat conversation:

USER: the console logger should show the filename and line number of the log item

ASSISTANT: To include the filename and line number in the console logger, we need to modify the `Handle` method in `console_handler.go` to extract and display this information.

Here is the *SEARCH/REPLACE* block:

console_handler.go
```go
<<<<<<< SEARCH
	timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
	var colorFunc func(format string, a ...interface{}) string

	switch record.Level {
	case slog.LevelInfo:
		colorFunc = color.New(color.FgBlue).SprintfFunc()
	case slog.LevelWarn:
		colorFunc = color.New(color.FgYellow).SprintfFunc()
	case slog.LevelError:
		colorFunc = color.New(color.FgRed).SprintfFunc()
	default:
		colorFunc = color.New(color.FgWhite).SprintfFunc()
	}

	fmt.Println(colorFunc("%s [%s]: %s", timestamp, record.Level, record.Message))
	return nil
=======
	timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00")
	var colorFunc func(format string, a ...interface{}) string

	switch record.Level {
	case slog.LevelInfo:
		colorFunc = color.New(color.FgBlue).SprintfFunc()
	case slog.LevelWarn:
		colorFunc = color.New(color.FgYellow).SprintfFunc()
	case slog.LevelError:
		colorFunc = color.New(color.FgRed).SprintfFunc()
	default:
		colorFunc = color.New(color.FgWhite).SprintfFunc()
	}

	// Extract the source file and line number
	source := record.Source
	fmt.Println(colorFunc("%s [%s] %s:%d: %s", timestamp, record.Level, source.File, source.Line, record.Message))
	return nil
>>>>>>> REPLACE
```
2024-05-14 05:16:40 -07:00
30 changed files with 1006 additions and 341 deletions

View 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: script/cibuild

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.aider* .aider*
cmd/example/example

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Lint stage: format check + golangci-lint
# golangci-lint v1.64.8 (2025-02-18)
FROM golangci/golangci-lint@sha256:2987913e27f4eca9c8a39129d2c7bc1e74fbcf77f181e01cea607be437aa5cb8 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Test stage: run full test suite
# golang 1.22.12 (2025-02-04)
FROM golang@sha256:1cf6c45ba39db9fd6db16922041d074a63c935556a05c5ccb62d181034df7f02 AS test
# Depend on lint stage so both stages always run
COPY --from=lint /src/go.sum /dev/null
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test

14
LICENSE Normal file
View File

@@ -0,0 +1,14 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@@ -1,14 +1,30 @@
.PHONY: test .PHONY: bootstrap setup test fmt fmt-check lint check docker hooks
default: test default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
test: test:
@go test -v ./... @script/test
fmt: fmt:
goimports -l -w . @script/fmt
golangci-lint run --fix
fmt-check:
@script/fmt-check
lint: lint:
golangci-lint run @script/lint
sh -c 'test -z "$$(gofmt -l .)"'
check:
@script/check
docker:
@script/docker
hooks:
@script/install-precommit

View File

@@ -1,5 +1,7 @@
# simplelog # simplelog
## Summary
simplelog is an opinionated logging package designed to facilitate easy and simplelog is an opinionated logging package designed to facilitate easy and
structured logging in Go applications with an absolute minimum of structured logging in Go applications with an absolute minimum of
boilerplate. boilerplate.
@@ -7,12 +9,19 @@ boilerplate.
The idea is that you can add a single import line which replaces the The idea is that you can add a single import line which replaces the
stdlib `log/slog` default handler, and solve the 90% case for logging. stdlib `log/slog` default handler, and solve the 90% case for logging.
## Current Status
Released v1.0.0 2024-06-14. Works as intended. No known bugs.
## Features ## Features
* if output is a tty, outputs pretty color logs - if output is a tty, outputs pretty color logs
* if output is not a tty, outputs json - if output is not a tty, outputs json
* supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp) - supports delivering each log message via a webhook
* supports delivering each log message via a webhook
## Planned Features
- supports delivering logs via tcp RELP (e.g. to remote rsyslog using imrelp)
## Installation ## Installation
@@ -25,19 +34,21 @@ go mod init your_project_name
Then, add SimpleLog to your project: Then, add SimpleLog to your project:
```bash ```bash
go get git.eeqj.de/sneak/go-simplelog go get sneak.berlin/go/simplelog
``` ```
## Usage ## Usage
Below is an example of how to use SimpleLog in a Go application. This example is provided in the form of a `main.go` file, which demonstrates logging at various levels using structured logging syntax. Below is an example of how to use SimpleLog in a Go application. This
example is provided in the form of a `main.go` file, which demonstrates
logging at various levels using structured logging syntax.
```go ```go
package main package main
import ( import (
"log/slog" "log/slog"
_ "git.eeqj.de/sneak/go-simplelog" _ "sneak.berlin/go/simplelog"
) )
func main() { func main() {
@@ -48,3 +59,41 @@ func main() {
slog.Error("Failed to save data", slog.String("reason", "permission denied")) slog.Error("Failed to save data", slog.String("reason", "permission denied"))
} }
``` ```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide:
- `script/bootstrap` — install all dependencies (go and golangci-lint if
missing, then `go mod download`)
- `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by
`script/docker` for the image tag
- `script/test` — run the test suite (`go test -v ./...`)
- `script/lint` — run golangci-lint
- `script/fmt` — format all files (goimports plus `golangci-lint run --fix`;
writes)
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
reports files
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension)
- `script/docker` — build the Docker image, tagged via `script/projectname`
(byte-identical across repos)
- `script/cibuild` — cd to the repo root and `docker build .` (what CI runs;
the image build runs the checks)
- `script/precommit` — run by the git pre-commit hook (our own extension);
runs a `go mod tidy` guard, then `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it
`make hooks` installs the pre-commit hook that runs `script/precommit`.
## License
[WTFPL](./LICENSE)

408
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,408 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

55
TODO.md Normal file
View File

@@ -0,0 +1,55 @@
# 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-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
* 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)

26
cmd/example/main.go Normal file
View File

@@ -0,0 +1,26 @@
package main
import (
"log/slog"
_ "sneak.berlin/go/simplelog"
)
func main() {
// log structured data with slog as usual:
slog.Info(
"User login attempt",
slog.String("user", "JohnDoe"),
slog.Int("attempt", 3),
)
slog.Warn(
"Configuration mismatch",
slog.String("expected", "config.json"),
slog.String("found", "config.dev.json"),
)
slog.Error(
"Failed to save data",
slog.String("reason", "permission denied"),
)
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"runtime"
"time" "time"
"github.com/fatih/color" "github.com/fatih/color"
@@ -15,7 +16,10 @@ func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{} return &ConsoleHandler{}
} }
func (c *ConsoleHandler) Handle(ctx context.Context, record slog.Record) error { func (c *ConsoleHandler) Handle(
ctx context.Context,
record slog.Record,
) 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 ...interface{}) string
@@ -30,11 +34,29 @@ func (c *ConsoleHandler) Handle(ctx context.Context, record slog.Record) error {
colorFunc = color.New(color.FgWhite).SprintfFunc() colorFunc = color.New(color.FgWhite).SprintfFunc()
} }
fmt.Println(colorFunc("%s [%s]: %s", timestamp, record.Level, record.Message)) // Get the caller information
_, file, line, ok := runtime.Caller(4)
if !ok {
file = "???"
line = 0
}
fmt.Println(
colorFunc(
"%s [%s] %s:%d: %s",
timestamp,
record.Level,
file,
line,
record.Message,
),
)
return nil return nil
} }
func (c *ConsoleHandler) Enabled(ctx context.Context, level slog.Level) bool { func (c *ConsoleHandler) Enabled(
ctx context.Context,
level slog.Level,
) bool {
return true return true
} }

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/go-simplelog module sneak.berlin/go/simplelog
go 1.22.1 go 1.22.1

View File

@@ -3,6 +3,6 @@ package simplelog
import "log/slog" import "log/slog"
// Handler defines the interface for different log outputs. // Handler defines the interface for different log outputs.
type Handler interface { type ExtendedHandler interface {
slog.Handler slog.Handler
} }

View File

@@ -3,11 +3,9 @@ package simplelog
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"context" "fmt"
"encoding/json"
"log"
"log/slog" "log/slog"
"log" "os"
) )
type JSONHandler struct{} type JSONHandler struct{}
@@ -18,7 +16,7 @@ func NewJSONHandler() *JSONHandler {
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error { func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, _ := json.Marshal(record) jsonData, _ := json.Marshal(record)
log.Println(string(jsonData)) fmt.Fprintln(os.Stdout, string(jsonData))
return nil return nil
} }

33
json_handler_test.go Normal file
View File

@@ -0,0 +1,33 @@
package simplelog
import (
"log/slog"
"testing"
"time"
)
// 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()
// 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() …
slog.Info("test message")
close(done)
}()
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("JSONHandler.Handle deadlocked: timed out after 5 seconds")
}
}

View File

@@ -1,264 +0,0 @@
package simplelog
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"log/slog"
"net"
"net/url"
"os"
"os"
"path/filepath"
"strconv"
"time"
"github.com/google/uuid"
)
func getEnvAsInt(name string, defaultVal int) int {
valStr := os.Getenv(name)
if val, err := strconv.Atoi(valStr); err == nil {
return val
}
return defaultVal
}
func getEnvAsDuration(name string, defaultVal time.Duration) time.Duration {
valStr := os.Getenv(name)
if val, err := time.ParseDuration(valStr); err == nil {
return val
}
return defaultVal
}
var (
cacheDir, _ = os.UserCacheDir()
diskBufferLimit = getEnvAsInt("LOGGER_DISK_BUFFER_LIMIT", 100)
diskWriteInterval = getEnvAsDuration("LOGGER_DISK_WRITE_INTERVAL", time.Second)
relpDebug = os.Getenv("RELP_DEBUG") != ""
)
type RELPHandler struct {
relpServerURL string
conn net.Conn
ch chan Event
done chan struct{}
failedCh chan Event
timer *time.Timer
}
func NewRELPHandler(relpURL string) (*RELPHandler, error) {
parsedURL, err := url.Parse(relpURL)
if err != nil {
return nil, fmt.Errorf("Error parsing RELP URL: %v", err)
}
if parsedURL.Scheme != "tcp" {
return nil, fmt.Errorf("RELP URL must have the tcp scheme, got %s", parsedURL.Scheme)
}
if err := os.MkdirAll(filepath.Join(cacheDir, "simplelog"), 0755); err != nil {
return nil, fmt.Errorf("Failed to create cache directory: %v", err)
}
r := &RELPHandler{
relpServerURL: parsedURL.Host,
ch: make(chan Event, diskBufferLimit),
done: make(chan struct{}),
failedCh: make(chan Event, diskBufferLimit),
timer: time.NewTimer(diskWriteInterval),
}
if relpDebug {
log.Printf("Created new RELP handler for server at %s", r.relpServerURL)
}
return r, nil
}
func (r *RELPHandler) connectToRELPServer() (net.Conn, error) {
parsedURL, err := url.Parse(r.relpServerURL)
if err != nil {
return nil, fmt.Errorf("Error parsing RELP URL: %v", err)
}
if parsedURL.Scheme != "tcp" {
return nil, fmt.Errorf("RELP URL must have the tcp scheme, got %s", parsedURL.Scheme)
}
host, port, err := net.SplitHostPort(parsedURL.Host)
if err != nil {
return nil, fmt.Errorf("Error splitting host and port: %v", err)
}
conn, err := net.Dial("tcp", net.JoinHostPort(host, port))
if err != nil {
if relpDebug {
log.Printf("Failed to connect to RELP server at %s: %v", net.JoinHostPort(host, port), err)
}
return nil, err
}
if relpDebug {
log.Printf("Successfully connected to RELP server at %s", net.JoinHostPort(host, port))
}
return conn, nil
}
func (r *RELPHandler) Startup() error {
var err error
r.conn, err = r.connectToRELPServer()
if err != nil {
if relpDebug {
log.Printf("Failed to establish TCP connection to RELP server: %v", err)
}
return fmt.Errorf("Failed to establish TCP connection to RELP server: %v", err)
}
if relpDebug {
log.Printf("Successfully connected to RELP server at %s", r.relpServerURL)
}
go r.receiveEventsFromChannel()
go r.processFailedEvents()
go r.watchDirectoryForFailedEventFiles()
return nil
}
func (r *RELPHandler) Enabled(ctx context.Context, level slog.Level) bool {
return true
}
func (r *RELPHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return r
}
func (r *RELPHandler) WithGroup(name string) slog.Handler {
return r
}
func (r *RELPHandler) Handle(ctx context.Context, record slog.Record) error {
attrs := make(map[string]interface{})
record.Attrs(func(attr slog.Attr) bool {
attrs[attr.Key] = attr.Value
return true
})
jsonData, err := json.Marshal(attrs)
if err != nil {
return fmt.Errorf("error marshaling attributes: %v", err)
}
event := NewEvent(record.Level.String(), record.Message, jsonData)
select {
case r.ch <- event:
return nil // Successfully sent event to channel
default:
return fmt.Errorf("failed to log event: channel is full")
}
}
func (r *RELPHandler) receiveEventsFromChannel() {
for {
select {
case event := <-r.ch:
if err := r.sendEventToRELPServer(event); err != nil {
r.failedCh <- event
}
case <-r.done:
return
}
}
}
func (r *RELPHandler) processFailedEvents() {
for {
select {
case event := <-r.failedCh:
if err := r.sendEventToRELPServer(event); err != nil {
// If still failing, write to disk
r.writeFailedToDisk(event)
}
case <-r.timer.C:
// Flush all events in failedCh to disk
for len(r.failedCh) > 0 {
event := <-r.failedCh
r.writeFailedToDisk(event)
}
case <-r.done:
return
}
}
}
func (r *RELPHandler) sendEventToRELPServer(event Event) error {
jsonData, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("error marshaling event: %v", err)
}
_, err = r.conn.Write(jsonData)
if err != nil {
return err
}
// Implement TCP read for the acknowledgment with a timeout
r.conn.SetReadDeadline(time.Now().Add(2 * time.Second))
ack := make([]byte, 256)
n, err := r.conn.Read(ack)
if err != nil {
return err
}
if string(ack[:n]) != "ACK" {
return fmt.Errorf("expected ACK from server, got %s", string(ack[:n]))
}
if relpDebug {
log.Printf("Received ACK from RELP server for event %s", event.ID)
}
return nil
}
func (r *RELPHandler) watchDirectoryForFailedEventFiles() {
for {
select {
case <-r.done:
return
default:
if r.isConnected() {
if relpDebug {
log.Printf("Reconnected to RELP server at %s", r.relpServerURL)
}
files, err := ioutil.ReadDir(cacheDir)
if err != nil {
log.Printf("Error reading cache directory: %v", err)
continue
}
for _, file := range files {
filePath := filepath.Join(cacheDir, file.Name())
data, err := ioutil.ReadFile(filePath)
if err != nil {
log.Printf("Error reading event cache file %s: %v", file.Name(), err)
continue
}
var event Event
if err := json.Unmarshal(data, &event); err != nil {
log.Printf("Error unmarshalling event from file %s: %v", file.Name(), err)
continue
}
r.ch <- event
os.Remove(filePath) // Remove file after processing
}
}
time.Sleep(10 * time.Second) // Check disk every 10 seconds
}
}
}
func (r *RELPHandler) isConnected() bool {
_, err := r.conn.Write([]byte{})
return err == nil
}
func (r *RELPHandler) Shutdown() error {
close(r.done)
if r.conn != nil {
if err := r.conn.Close(); err != nil {
return fmt.Errorf("error closing TCP connection: %v", err)
}
}
return nil
}
func (r *RELPHandler) writeFailedToDisk(event Event) {
fileName := filepath.Join(cacheDir, "simplelog", uuid.New().String()+".logevent")
data, _ := json.Marshal(event)
ioutil.WriteFile(fileName, data, 0600)
}

69
script/bootstrap Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
if missing go; then pkg_install go golang go go; fi
# golangci-lint is packaged in nix, brew, and apk; there is no apt
# package (on apt hosts, install it from a hash-verified GitHub
# release archive manually, never curl | sh).
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
fi
go mod download
echo "bootstrap complete"
}
main "$@"

14
script/check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

13
script/cibuild Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

14
script/docker Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

13
script/fmt Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
goimports -l -w .
golangci-lint run --fix
}
main "$@"

18
script/fmt-check Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Fails and lists the
# offending files if gofmt would reformat anything.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt would reformat:"
echo "$unformatted"
exit 1
fi
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

12
script/lint Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
golangci-lint run
}
main "$@"

19
script/precommit Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
git diff --exit-code -- go.mod go.sum || {
echo "go mod tidy changed go.mod/go.sum; stage the changes and retry" >&2
exit 1
}
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "simplelog"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

12
script/test Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v ./...
}
main "$@"

View File

@@ -2,79 +2,41 @@ package simplelog
import ( import (
"context" "context"
"encoding/json"
"log" "log"
"log/slog" "log/slog"
"os" "os"
"time"
"github.com/mattn/go-isatty" "github.com/google/uuid"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
) )
var ( var (
relpServerURL = os.Getenv("LOGGER_RELP_URL") webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
webhookURL = os.Getenv("LOGGER_WEBHOOK_URL")
) )
type CustomHandler struct {
handlers []slog.Handler
}
func (cl *CustomHandler) Handle(ctx context.Context, record slog.Record) error {
for _, handler := range cl.handlers {
if err := handler.Handle(ctx, record); err != nil {
return err
}
}
return nil
}
func (cl *CustomHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, handler := range cl.handlers {
if handler.Enabled(ctx, level) {
return true
}
}
return false
}
func (cl *CustomHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
newHandlers := make([]slog.Handler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithAttrs(attrs)
}
return &CustomHandler{handlers: newHandlers}
}
func (cl *CustomHandler) WithGroup(name string) slog.Handler {
newHandlers := make([]slog.Handler, len(cl.handlers))
for i, handler := range cl.handlers {
newHandlers[i] = handler.WithGroup(name)
}
return &CustomHandler{handlers: newHandlers}
}
var ourCustomLogger *slog.Logger var ourCustomLogger *slog.Logger
var ourCustomHandler slog.Handler
func init() { func init() {
ourCustomLogger = NewCustomHandler() ourCustomHandler = NewMultiplexHandler()
ourCustomLogger = slog.New(ourCustomHandler)
slog.SetDefault(ourCustomLogger) slog.SetDefault(ourCustomLogger)
} }
func NewCustomHandler() *CustomHandler { type MultiplexHandler struct {
cl := &CustomHandler{} handlers []ExtendedHandler
}
func NewMultiplexHandler() slog.Handler {
cl := &MultiplexHandler{}
if isatty.IsTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) {
cl.handlers = append(cl.handlers, NewConsoleHandler()) cl.handlers = append(cl.handlers, NewConsoleHandler())
} else { } else {
cl.handlers = append(cl.handlers, NewJSONHandler()) cl.handlers = append(cl.handlers, NewJSONHandler())
} }
if relpServerURL != "" {
handler, err := NewRELPHandler(relpServerURL)
if err != nil {
log.Fatalf("Failed to initialize RELP handler: %v", err)
}
cl.handlers = append(cl.handlers, handler)
}
if webhookURL != "" { if webhookURL != "" {
handler, err := NewWebhookHandler(webhookURL) handler, err := NewWebhookHandler(webhookURL)
if err != nil { if err != nil {
@@ -84,3 +46,91 @@ func NewCustomHandler() *CustomHandler {
} }
return cl return cl
} }
func (cl *MultiplexHandler) Handle(
ctx context.Context,
record slog.Record,
) error {
for _, handler := range cl.handlers {
if err := handler.Handle(ctx, record); err != nil {
return err
}
}
return nil
}
func (cl *MultiplexHandler) Enabled(
ctx context.Context,
level slog.Level,
) bool {
// send us all events
return true
}
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}
}
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}
}
type ExtendedEvent interface {
GetID() uuid.UUID
GetTimestamp() time.Time
GetLevel() string
GetMessage() string
GetData() json.RawMessage
GetFile() string
GetLine() int
}
type extendedEvent struct {
Event
File string `json:"file"`
Line int `json:"line"`
}
func (e extendedEvent) GetID() uuid.UUID {
return e.ID
}
func (e extendedEvent) GetTimestamp() time.Time {
return e.Timestamp
}
func (e extendedEvent) GetLevel() string {
return e.Level
}
func (e extendedEvent) GetMessage() string {
return e.Message
}
func (e extendedEvent) GetData() json.RawMessage {
return e.Data
}
func (e extendedEvent) GetFile() string {
return e.File
}
func (e extendedEvent) GetLine() int {
return e.Line
}
func NewExtendedEvent(baseEvent Event, file string, line int) ExtendedEvent {
return extendedEvent{
Event: baseEvent,
File: file,
Line: line,
}
}

View File

@@ -3,7 +3,7 @@ package main
import ( import (
"log/slog" "log/slog"
_ "git.eeqj.de/sneak/go-simplelog" // Using underscore to only invoke init() _ "sneak.berlin/go/simplelog" // Using underscore to only invoke init()
) )
func main() { func main() {

View File

@@ -2,7 +2,7 @@
# Set the environment variables for the RELP server URL and optionally for the webhook URL # Set the environment variables for the RELP server URL and optionally for the webhook URL
export LOGGER_RELP_URL="tcp://10.201.1.18:20514" export LOGGER_RELP_URL="tcp://10.201.1.18:20514"
export LOGGER_WEBHOOK_URL="https://example.com/webhook" #export LOGGER_WEBHOOK_URL="https://example.com/webhook"
export RELP_DEBUG=1 export RELP_DEBUG=1
# Run the Go program # Run the Go program