2 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
22 changed files with 692 additions and 1264 deletions

View File

@@ -9,4 +9,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: docker build .
- run: script/cibuild

View File

@@ -1,28 +1,30 @@
.PHONY: test fmt fmt-check lint check docker hooks
.PHONY: bootstrap setup test fmt fmt-check lint check docker hooks
default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
test:
@go test -v ./...
@script/test
fmt:
goimports -l -w .
golangci-lint run --fix
@script/fmt
fmt-check:
@test -z "$$(gofmt -l .)" || { echo "gofmt would reformat:"; gofmt -l .; exit 1; }
@script/fmt-check
lint:
golangci-lint run
@script/lint
check: fmt-check lint test
check:
@script/check
docker:
docker build --progress plain .
@script/docker
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."
@script/install-precommit

View File

@@ -18,13 +18,6 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
- if output is a tty, outputs pretty color logs
- if output is not a tty, outputs json
- supports delivering each log message via a webhook
- emits every `slog` attribute: those passed to a log call, those
accumulated with `WithAttrs`, and those qualified by `WithGroup`.
`slog.Group` values nest, and `slog.LogValuer` values are resolved. In
json output attributes are object fields (groups become nested objects);
in console output they are appended as `key=value` pairs, with grouped
keys written as `group.key=value`. See
[Attribute output](#attribute-output) for the details worth knowing
## Planned Features
@@ -67,42 +60,39 @@ func main() {
}
```
## Attribute output
## Entrypoints
Attributes reach every handler: the ones passed to the log call, the ones
accumulated with `WithAttrs`, and the ones qualified by the groups open at
the time they were attached. `slog.Group` values nest, and
`slog.LogValuer` values are resolved to the value they stand for. A few
behaviours are worth knowing before you rely on them.
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:
**Your values are never modified.** Whatever you log is read and rendered,
never written to. A map or a slice you pass to `slog.Any` comes back from
the logger exactly as you handed it over, even when a group later uses the
same key.
- `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
**Durations are nanoseconds in json, and readable on the console.** The
json and webhook payloads emit a `slog.Duration` as a number of
nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and
aggregate the field without parsing it. The console line emits the same
duration as `3s`, matching `slog.NewTextHandler`, because a person reads
it.
**The json payload is an object, with the consequences an object has.**
The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
they own those names: an attribute keyed after one of them is dropped from
the json and webhook output. A key logged more than once keeps its last
value there for the same reason, with one exception: when both are
`slog.Group` values sharing a key, the two groups are merged into one
object holding the members of both, rather than the second replacing the
first. Neither the collision nor the merge applies to the console output,
which is a line of text: every pair appears, in order. If you need a field
called `message`, pick a key that does not collide - the collision is
silent.
**Empty things follow the `slog.Handler` contract.** An empty `Attr` is
ignored, an empty group is elided along with its key, a group with an
empty key is inlined into its parent, and `WithGroup("")` returns the
handler unchanged.
`make hooks` installs the pre-commit hook that runs `script/precommit`.
## 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`

View File

@@ -24,10 +24,8 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# Completed Steps
* 2026-08-10: fixed every handler discarding slog attributes: console,
JSON and webhook handlers now emit record attributes, accumulate
WithAttrs without mutating the receiver, and honour WithGroup;
slog.Group values nest and LogValuer values are resolved
- 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
@@ -50,8 +48,6 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
the working tree
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
inconsistent)
* Tag v1.0.2, with the leading v, once the attribute fix lands, so
consuming repos can move off pseudo-version pins in one step
* 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)

255
attrs.go
View File

@@ -1,255 +0,0 @@
package simplelog
import (
"encoding/json"
"log/slog"
"strconv"
"strings"
"unicode"
)
// handlerAttrs is the attribute state every handler carries: the attributes
// accumulated by WithAttrs, plus the groups opened by WithGroup. Its methods
// never mutate the receiver, so handlers derived from a common parent stay
// independent of each other.
type handlerAttrs struct {
attrs []slog.Attr
groups []string
}
// withAttrs returns a copy carrying attrs in addition to those already held.
// The attributes are qualified by the groups that are open at the time they
// are attached, as the slog.Handler contract requires.
func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
qualified := qualifyAttrs(h.groups, attrs)
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
combined = append(combined, h.attrs...)
combined = append(combined, qualified...)
return handlerAttrs{attrs: combined, groups: h.groups}
}
// withGroup returns a copy with a further group open. An empty name is a no-op,
// per the slog.Handler contract.
func (h handlerAttrs) withGroup(name string) handlerAttrs {
if name == "" {
return h
}
groups := make([]string, 0, len(h.groups)+1)
groups = append(groups, h.groups...)
groups = append(groups, name)
return handlerAttrs{attrs: h.attrs, groups: groups}
}
// forRecord returns the accumulated attributes followed by the record's own,
// the latter qualified by any open groups.
func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
own := qualifyAttrs(h.groups, recordAttrs(record))
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
all = append(all, h.attrs...)
all = append(all, own...)
return all
}
// qualifyAttrs nests attrs inside the given open groups, innermost last.
func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
for i := len(groups) - 1; i >= 0; i-- {
attrs = []slog.Attr{{
Key: groups[i],
Value: slog.GroupValue(attrs...),
}}
}
return attrs
}
// recordAttrs collects the attributes a record carries. They live in
// unexported fields, so they are only reachable through Record.Attrs - which
// is why marshaling a slog.Record directly loses every one of them.
func recordAttrs(record slog.Record) []slog.Attr {
attrs := make([]slog.Attr, 0, record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
return attrs
}
// groupMap is a JSON object this package built for itself: the payload of a
// record, or one of the nested objects a slog.Group becomes.
//
// The distinct type is what keeps the handler from writing into data the
// caller still owns. A value handed to slog.Any goes into the payload by
// reference - copying every logged map and slice would be a real cost for no
// gain, since rendering only reads. The one place the handler writes into a
// value already in the payload is the group merge below, and a type assertion
// to groupMap can only succeed on a map this package allocated: a caller's
// map[string]any is a different type and never matches, however it was keyed.
// So the invariant holds by construction - nothing reachable from the caller
// is ever written to, only read.
type groupMap map[string]any
// recordToMap renders a record, with its handler's attributes, as the JSON
// object the JSON and webhook handlers emit. The record's own fields keep the
// names they have always had, and win a collision with an attribute key.
func recordToMap(record slog.Record, attrs handlerAttrs) groupMap {
fields := attrsToMap(attrs.forRecord(record))
fields["Time"] = record.Time
fields["Level"] = record.Level
fields["Message"] = record.Message
fields["PC"] = record.PC
return fields
}
// attrsToMap renders attributes as a JSON object in which groups are nested
// objects. A group named more than once is merged rather than duplicated;
// any other repeated key keeps the last value, as a JSON object must.
func attrsToMap(attrs []slog.Attr) groupMap {
fields := make(groupMap, len(attrs))
for _, attr := range attrs {
addAttrToMap(fields, attr)
}
return fields
}
func addAttrToMap(fields groupMap, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
// An empty Attr is ignored, per the slog.Handler contract.
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
// An empty group is elided, as is its key.
return
}
// A group with an empty key is inlined into its parent.
target := fields
if attr.Key != "" {
// Merge only into a group this package built. Anything else
// at this key - including a map the caller logged - is
// replaced rather than written into, so the caller's own
// data structure is never touched.
nested, ok := fields[attr.Key].(groupMap)
if !ok {
nested = make(groupMap, len(group))
fields[attr.Key] = nested
}
target = nested
}
for _, member := range group {
addAttrToMap(target, member)
}
return
}
fields[attr.Key] = jsonValue(value)
}
// jsonValue converts a resolved slog.Value into something encoding/json can
// render usefully. Values it cannot marshal - and errors, which marshal to an
// empty object - fall back to their slog string form, so a value is never
// rendered as an empty object.
//
// Values are returned as they were given, not copied: nothing here or in its
// callers writes to a value the caller supplied.
func jsonValue(value slog.Value) any {
switch value.Kind() {
case slog.KindString:
return value.String()
case slog.KindInt64:
return value.Int64()
case slog.KindUint64:
return value.Uint64()
case slog.KindFloat64:
return value.Float64()
case slog.KindBool:
return value.Bool()
case slog.KindDuration:
// Nanoseconds as a number, which is what slog.NewJSONHandler
// emits. A JSON consumer can then compare and aggregate the
// field; the "3s" form would have to be parsed first, and no
// common log pipeline knows how.
return value.Duration().Nanoseconds()
case slog.KindTime:
return value.Time()
default:
// KindAny, and anything a future Go release adds.
return jsonAnyValue(value)
}
}
func jsonAnyValue(value slog.Value) any {
held := value.Any()
if _, ok := held.(json.Marshaler); !ok {
if err, ok := held.(error); ok {
return err.Error()
}
}
if _, err := json.Marshal(held); err != nil {
return value.String()
}
return held
}
// attrsToText renders attributes as the space separated key=value pairs the
// console handler appends to a log line. Groups become dotted key prefixes.
//
// Values take their slog string form, so a duration reads as "3s" here where
// the json output carries nanoseconds as a number. That is the same split the
// stdlib makes between slog.NewTextHandler and slog.NewJSONHandler: the
// console line is read by a person, the json line by a program.
func attrsToText(attrs []slog.Attr) string {
var out strings.Builder
for _, attr := range attrs {
appendAttrText(&out, "", attr)
}
return out.String()
}
func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
return
}
nested := prefix
if attr.Key != "" {
nested = prefix + attr.Key + "."
}
for _, member := range group {
appendAttrText(out, nested, member)
}
return
}
out.WriteString(" ")
// The key is quoted on the same terms as the value, and as a whole
// including its group prefix, because that is the token a reader has to
// find the "=" in. slog.NewTextHandler quotes "prefix+key" the same way,
// so a key like "a=b" reads as "a=b"=v2 rather than the ambiguous
// a=b=v2, which parses as the key "a" with the value "b=v2".
out.WriteString(quoteIfNeeded(prefix + attr.Key))
out.WriteString("=")
out.WriteString(quoteIfNeeded(value.String()))
}
// quoteIfNeeded quotes a key or a value only when leaving it bare would make
// the key=value pairs ambiguous, matching how the stdlib text handler reads.
func quoteIfNeeded(text string) string {
if text == "" {
return `""`
}
for _, r := range text {
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == '"' || r == '=' {
return strconv.Quote(text)
}
}
return text
}

View File

@@ -1,905 +0,0 @@
package simplelog
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
"time"
)
// These tests assert on the bytes the handlers actually emit, because that is
// where the defect lives: the handlers accept attributes and then throw them
// away, so nothing short of reading the output proves they survived.
// captureStdout redirects os.Stdout for the duration of fn and returns what was
// written to it. Both the console and the JSON handler write to os.Stdout, so
// this is the only way to see their real output.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
collected := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
collected <- buf.String()
}()
defer func() {
os.Stdout = original
_ = r.Close()
}()
fn()
os.Stdout = original
if err := w.Close(); err != nil {
t.Fatalf("close pipe writer: %v", err)
}
return <-collected
}
// testRecord builds an INFO record carrying the given attributes.
func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...)
return record
}
// decodeLine parses a single line of JSON handler output.
func decodeLine(t *testing.T, output string) map[string]any {
t.Helper()
line := strings.TrimSpace(output)
if line == "" {
t.Fatal("handler emitted no output")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
}
return decoded
}
// wantField asserts that a decoded JSON object has key with the given value.
func wantField(t *testing.T, decoded map[string]any, key string, want any) {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("field %q missing from output: %v", key, decoded)
}
if got != want {
t.Fatalf("field %q = %v, want %v", key, got, want)
}
}
// wantGroup asserts that a decoded JSON object has key holding a nested object.
func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("group %q missing from output: %v", key, decoded)
}
group, ok := got.(map[string]any)
if !ok {
t.Fatalf("field %q = %v, want a nested object", key, got)
}
return group
}
// wantNoField asserts that a decoded JSON object does not carry key.
func wantNoField(t *testing.T, decoded map[string]any, key string) {
t.Helper()
if got, present := decoded[key]; present {
t.Fatalf("field %q unexpectedly present as %v: %v", key, got, decoded)
}
}
// wantUnchanged asserts that a value the caller still owns was not written to
// by the handler. Logging must read what it is handed, never modify it.
func wantUnchanged(t *testing.T, what string, got, want any) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("handler mutated the caller's %s: got %v, want %v", what, got, want)
}
}
// wantContains asserts that console output contains a fragment.
func wantContains(t *testing.T, output, fragment string) {
t.Helper()
if !strings.Contains(output, fragment) {
t.Fatalf("output does not contain %q\noutput: %s", fragment, output)
}
}
// wantNotContains asserts that console output does not contain a fragment.
func wantNotContains(t *testing.T, output, fragment string) {
t.Helper()
if strings.Contains(output, fragment) {
t.Fatalf("output unexpectedly contains %q\noutput: %s", fragment, output)
}
}
// castTarget is a slog.LogValuer: the handler must resolve it rather than
// serialising the struct itself.
type castTarget struct {
id string
}
func (c castTarget) LogValue() slog.Value {
return slog.StringValue(c.id)
}
var _ slog.LogValuer = castTarget{}
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
wantField(t, decoded, "device", "livingroom")
wantField(t, decoded, "file", "movie.mp4")
wantField(t, decoded, "attempt", float64(3))
}
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "component", "caster")
wantField(t, decoded, "device", "livingroom")
}
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewJSONHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantField(t, decodeLine(t, firstOutput), "worker", "first")
wantField(t, decodeLine(t, secondOutput), "worker", "second")
if _, present := decodeLine(t, parentOutput)["worker"]; present {
t.Fatalf("parent handler leaked an attribute from a derived handler: %s", parentOutput)
}
}
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
group := wantGroup(t, decoded, "cast")
wantField(t, group, "device", "livingroom")
wantField(t, group, "file", "movie.mp4")
}
func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "target", "chromecast-7")
wantField(t, decoded, "error", "connection refused")
group := wantGroup(t, decoded, "request")
wantField(t, group, "status", float64(502))
wantField(t, group, "method", "POST")
}
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "casting")
wantContains(t, output, "device=livingroom")
wantContains(t, output, "file=movie.mp4")
wantContains(t, output, "attempt=3")
}
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, `file="The Movie.mp4"`)
}
// TestConsoleHandlerQuotesKeysNeedingIt pins the key side of the same rule.
// A key is quoted on the same terms as a value, and as one token including
// its group prefix, so that the "=" separating the pair is always the first
// one outside quotes. Every case here was compared against
// slog.NewTextHandler, which renders each of them identically.
func TestConsoleHandlerQuotesKeysNeedingIt(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
want string
notWant string
}{
{
name: "an ordinary key is left bare",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("device", "livingroom")},
want: " device=livingroom",
notWant: `"device"`,
},
{
name: "a key containing an equals sign is quoted",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("a=b", "v2")},
want: ` "a=b"=v2`,
notWant: " a=b=v2",
},
{
name: "a key containing a space is quoted",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("my key", "v")},
want: ` "my key"=v`,
notWant: " my key=v",
},
{
name: "a key containing a quote is escaped",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String(`he"llo`, "v")},
want: ` "he\"llo"=v`,
notWant: ` he"llo=v`,
},
{
name: "a printable non-ascii key is left bare",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("キー", "v")},
want: " キー=v",
notWant: `"キー"`,
},
{
name: "a group prefix is quoted together with its key",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("grp", slog.String("a=b", "v")),
},
want: ` "grp.a=b"=v`,
notWant: " grp.a=b=v",
},
{
name: "a WithGroup prefix needing quotes quotes the whole key",
build: func() slog.Handler {
return NewConsoleHandler().WithGroup("my grp")
},
attrs: []slog.Attr{slog.String("k", "v")},
want: ` "my grp.k"=v`,
notWant: " my grp.k=v",
},
{
name: "an empty key is quoted rather than left as a gap",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{slog.String("", "v")},
want: ` ""=v`,
notWant: " =v",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, test.want)
wantNotContains(t, output, test.notWant)
})
}
}
func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "service=cattbox")
wantContains(t, output, "component=caster")
wantContains(t, output, "device=livingroom")
}
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewConsoleHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, firstOutput, "worker=first")
wantNotContains(t, firstOutput, "worker=second")
wantContains(t, secondOutput, "worker=second")
wantNotContains(t, secondOutput, "worker=first")
wantNotContains(t, parentOutput, "worker=")
}
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "cast.device=livingroom")
wantContains(t, output, "cast.file=movie.mp4")
}
func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502)),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "request.status=502")
wantContains(t, output, "target=chromecast-7")
wantContains(t, output, `error="connection refused"`)
}
func TestWebhookHandlerEmitsAttrs(t *testing.T) {
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := withAttrs.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
decoded := decodeLine(t, string(body))
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() {
handler := (&MultiplexHandler{handlers: []ExtendedHandler{NewJSONHandler()}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// A logging library must read the values it is handed and never write to them.
// The dangerous shape is a group that shares a key with something the caller
// logged by reference: merging the group's members into whatever already sits
// at that key would reach straight back into the caller's own data structure.
// The next four tests hold every handler to that, through both the record path
// and the derived-logger path.
func TestJSONHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
// The later attribute wins the key outright, as any repeated key does.
group := wantGroup(t, decodeLine(t, output), "g")
wantField(t, group, "injected", float64(1))
wantNoField(t, group, "mine")
}
func TestJSONHandlerDoesNotMutateCallerMapThroughWithAttrs(t *testing.T) {
caller := map[string]any{"id": "req-1"}
output := captureStdout(t, func() {
handler := NewJSONHandler().
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
group := wantGroup(t, decodeLine(t, output), "req")
wantField(t, group, "status", float64(502))
wantNoField(t, group, "id")
}
// TestJSONHandlerDoesNotMutateCallerSlice is the slice form of the same
// hazard: a caller's slice is also handed over by reference, and appending
// into its spare capacity would be just as visible to the caller as writing
// into its map. The slice is built with room to spare so that an append within
// capacity cannot hide behind an unchanged length.
func TestJSONHandlerDoesNotMutateCallerSlice(t *testing.T) {
caller := make([]any, 2, 4)
caller[0] = "first"
caller[1] = "second"
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"casting",
slog.Any("s", caller),
slog.Group("s", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantUnchanged(t, "slice", caller, []any{"first", "second"})
wantUnchanged(
t,
"slice backing array",
caller[:cap(caller)],
[]any{"first", "second", nil, nil},
)
group := wantGroup(t, decodeLine(t, output), "s")
wantField(t, group, "injected", float64(1))
}
func TestConsoleHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"mine": "untouched"}
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"casting",
slog.Any("g", caller),
slog.Group("g", slog.Int("injected", 1)),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantUnchanged(t, "map", caller, map[string]any{"mine": "untouched"})
wantContains(t, output, "g.injected=1")
}
func TestWebhookHandlerDoesNotMutateCallerMap(t *testing.T) {
caller := map[string]any{"id": "req-1"}
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
derived := handler.
WithAttrs([]slog.Attr{slog.Any("req", caller)}).
WithGroup("req")
record := testRecord("cast failed", slog.Int("status", 502))
if err := derived.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
wantUnchanged(t, "map", caller, map[string]any{"id": "req-1"})
group := wantGroup(t, decodeLine(t, string(body)), "req")
wantField(t, group, "status", float64(502))
}
// TestJSONHandlerRendersDurationAsNanoseconds pins the json rendering of a
// duration to a number of nanoseconds, which is what slog.NewJSONHandler
// emits. A consumer that sums or compares the field needs a number; the "3s"
// form would silently arrive as a string.
func TestJSONHandlerRendersDurationAsNanoseconds(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
if _, isNumber := decoded["elapsed"].(float64); !isNumber {
t.Fatalf("field \"elapsed\" = %#v, want a json number", decoded["elapsed"])
}
wantField(t, decoded, "elapsed", float64((3 * time.Second).Nanoseconds()))
}
// TestConsoleHandlerRendersDurationReadably is the other half of that choice:
// the console line is read by a person, so it carries the same human-readable
// form slog.NewTextHandler uses.
func TestConsoleHandlerRendersDurationReadably(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord("casting", slog.Duration("elapsed", 3*time.Second))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "elapsed=3s")
}
// TestJSONHandlerContractEdgeCases pins the slog.Handler contract's four
// edge cases. Every case carries a control attribute as well, so a handler
// that emitted no attributes at all would fail rather than pass by omission.
//
// The empty group is attached through WithAttrs rather than to the record,
// because slog.Record.AddAttrs elides empty groups itself: routed through the
// record, the case would never reach the handler at all.
func TestJSONHandlerContractEdgeCases(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
verify func(t *testing.T, decoded map[string]any)
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return NewJSONHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
},
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return NewJSONHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "empty")
},
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return NewJSONHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
verify: func(t *testing.T, decoded map[string]any) {
wantField(t, decoded, "inner", "yes")
wantNoField(t, decoded, "")
},
},
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return NewJSONHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
verify: func(t *testing.T, decoded map[string]any) {
wantField(t, decoded, "kept", "yes")
wantNoField(t, decoded, "")
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
test.verify(t, decodeLine(t, output))
})
}
}
// TestConsoleHandlerContractEdgeCases holds the console handler to the same
// four rules, since it renders attributes through its own code path.
func TestConsoleHandlerContractEdgeCases(t *testing.T) {
tests := []struct {
name string
build func() slog.Handler
attrs []slog.Attr
want string
notWant string
}{
{
name: "empty attr is ignored",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{{}, slog.String("kept", "yes")},
want: "kept=yes",
notWant: " =",
},
{
name: "empty group is elided along with its key",
build: func() slog.Handler {
return NewConsoleHandler().
WithAttrs([]slog.Attr{slog.Group("empty")})
},
attrs: []slog.Attr{slog.String("kept", "yes")},
want: "kept=yes",
notWant: "empty=",
},
{
name: "group with an empty key is inlined",
build: func() slog.Handler { return NewConsoleHandler() },
attrs: []slog.Attr{
slog.Group("", slog.String("inner", "yes")),
},
want: " inner=yes",
notWant: ".inner=",
},
{
name: "WithGroup with an empty name is a no-op",
build: func() slog.Handler {
return NewConsoleHandler().WithGroup("")
},
attrs: []slog.Attr{slog.String("kept", "yes")},
want: " kept=yes",
notWant: ".kept=",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
output := captureStdout(t, func() {
handler := test.build()
record := testRecord("casting", test.attrs...)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, test.want)
wantNotContains(t, output, test.notWant)
})
}
}
// TestJSONHandlerRecordFieldsWinKeyCollision pins a caller-visible surprise:
// the json payload is an object, so the record's own fields own their names
// and an attribute keyed after one of them is dropped rather than emitted.
func TestJSONHandlerRecordFieldsWinKeyCollision(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"the real message",
slog.String("Time", "hijacked"),
slog.String("Level", "hijacked"),
slog.String("Message", "hijacked"),
slog.String("PC", "hijacked"),
slog.String("kept", "yes"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "kept", "yes")
wantField(t, decoded, "Message", "the real message")
wantField(t, decoded, "Level", "INFO")
for _, reserved := range []string{"Time", "PC"} {
if decoded[reserved] == "hijacked" {
t.Fatalf("attribute overwrote the record's own %q field: %v", reserved, decoded)
}
}
}
// TestJSONHandlerDuplicateKeysKeepLast pins the other consequence of the
// payload being an object: a key logged twice collapses to its last value.
func TestJSONHandlerDuplicateKeysKeepLast(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantField(t, decodeLine(t, output), "device", "livingroom")
}
// TestConsoleHandlerKeepsDuplicateKeys is the console counterpart: a line of
// text is not an object, so both pairs survive there.
func TestConsoleHandlerKeepsDuplicateKeys(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "kitchen"),
slog.String("device", "livingroom"),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "device=kitchen")
wantContains(t, output, "device=livingroom")
}

View File

@@ -10,9 +10,7 @@ import (
"github.com/fatih/color"
)
type ConsoleHandler struct {
attrs handlerAttrs
}
type ConsoleHandler struct{}
func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{}
@@ -44,13 +42,12 @@ func (c *ConsoleHandler) Handle(
}
fmt.Println(
colorFunc(
"%s [%s] %s:%d: %s%s",
"%s [%s] %s:%d: %s",
timestamp,
record.Level,
file,
line,
record.Message,
attrsToText(c.attrs.forRecord(record)),
),
)
return nil
@@ -64,15 +61,9 @@ func (c *ConsoleHandler) Enabled(
}
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return c
}
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
return c
}
func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
if name == "" {
return c
}
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
return c
}

View File

@@ -8,19 +8,14 @@ import (
"os"
)
type JSONHandler struct {
attrs handlerAttrs
}
type JSONHandler struct{}
func NewJSONHandler() *JSONHandler {
return &JSONHandler{}
}
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil {
return fmt.Errorf("error marshaling log record: %w", err)
}
jsonData, _ := json.Marshal(record)
fmt.Fprintln(os.Stdout, string(jsonData))
return nil
}
@@ -30,15 +25,9 @@ func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
}
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return j
}
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
return j
}
func (j *JSONHandler) WithGroup(name string) slog.Handler {
if name == "" {
return j
}
return &JSONHandler{attrs: j.attrs.withGroup(name)}
return j
}

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

@@ -12,7 +12,6 @@ import (
type WebhookHandler struct {
webhookURL string
attrs handlerAttrs
}
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
@@ -20,23 +19,11 @@ func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
}
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withAttrs(attrs),
}
return w
}
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
if name == "" {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withGroup(name),
}
return w
}
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
@@ -47,7 +34,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
}
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
jsonData, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("error marshaling event: %v", err)
}