1 Commits
Author SHA1 Message Date
clawbot d193c99cb7 Re-vendor the standard files from sneak/prompts dd4027b (closes #33)
check / check (push) Failing after 5s
REPO_POLICIES.md, .golangci.yml, the CI workflow and the scripts the
policy keeps identical across repositories are copies of the files at
that commit. .gitignore, .editorconfig and the new .dockerignore are the
canonical files followed by this repository's own entries. The lint
stage moves to the golangci-lint image the policy pins, in the same
commit as .golangci.yml. The format check leaves the lint stage and runs
on the host from script/check, which script/cibuild now runs after
script/bootstrap. The last Dockerfile stage runs script/bootstrap.
script/fmt runs goimports with go run at a pinned commit.

Model: opus-5-5
2026-10-06 12:49:32 +00:00
13 changed files with 165 additions and 465 deletions
+1 -6
View File
@@ -72,12 +72,7 @@
**/*.sublime-* **/*.sublime-*
# This repository's host-built artifacts: the example program's binary, # This repository's host-built artifacts: the example program's binary,
# test binaries, coverage output, and the log yarn writes when an install # test binaries and coverage output.
# fails.
/cmd/example/example /cmd/example/example
/*.test /*.test
/*.out /*.out
/yarn-error.log
# aider's files, among them a config file that can hold an API key.
**/.aider*
-2
View File
@@ -1,2 +0,0 @@
node_modules/
yarn.lock
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
+1 -1
View File
@@ -31,6 +31,6 @@ COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
WORKDIR /src WORKDIR /src
COPY script/ script/ COPY script/ script/
COPY go.mod go.sum package.json yarn.lock ./ COPY go.mod go.sum ./
RUN script/bootstrap RUN script/bootstrap
COPY . . COPY . .
+79 -77
View File
@@ -3,10 +3,11 @@
## Summary ## 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 boilerplate. structured logging in Go applications with an absolute minimum of
boilerplate.
The idea is that you can add a single import line which replaces the stdlib The idea is that you can add a single import line which replaces the
`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 ## Current Status
@@ -17,15 +18,16 @@ 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 a tty, outputs pretty color logs
- if output is not a tty, outputs json - if output is not a tty, outputs json
- supports delivering each log message via a webhook - supports delivering each log message via a webhook
- emits every `slog` attribute: those passed to a log call, those accumulated - emits every `slog` attribute: those passed to a log call, those
with `WithAttrs`, and those qualified by `WithGroup`. `slog.Group` values accumulated with `WithAttrs`, and those qualified by `WithGroup`.
nest, and `slog.LogValuer` values are resolved. In json output attributes are `slog.Group` values nest, and `slog.LogValuer` values are resolved. In
object fields (groups become nested objects); in console output they are json output attributes are object fields (groups become nested objects);
appended as `key=value` pairs, with grouped keys written as `group.key=value`. in console output they are appended as `key=value` pairs, with grouped
See [Attribute output](#attribute-output) for the details worth knowing keys written as `group.key=value`. See
- reports a record that could not be delivered as an error from the handler's [Attribute output](#attribute-output) for the details worth knowing
`Handle` method. See [When delivery fails](#when-delivery-fails) for how to - reports a record that could not be delivered as an error from the
receive it handler's `Handle` method. See
[When delivery fails](#when-delivery-fails) for how to receive it
## Planned Features ## Planned Features
@@ -47,9 +49,9 @@ go get sneak.berlin/go/simplelog
## Usage ## Usage
Below is an example of how to use SimpleLog in a Go application. This example is Below is an example of how to use SimpleLog in a Go application. This
provided in the form of a `main.go` file, which demonstrates logging at various example is provided in the form of a `main.go` file, which demonstrates
levels using structured logging syntax. logging at various levels using structured logging syntax.
```go ```go
package main package main
@@ -71,58 +73,61 @@ func main() {
## Attribute output ## Attribute output
Attributes reach every handler: the ones passed to the log call, the ones 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 accumulated with `WithAttrs`, and the ones qualified by the groups open at
time they were attached. `slog.Group` values nest, and `slog.LogValuer` values the time they were attached. `slog.Group` values nest, and
are resolved to the value they stand for. A few behaviours are worth knowing `slog.LogValuer` values are resolved to the value they stand for. A few
before you rely on them. behaviours are worth knowing before you rely on them.
**Your values are never modified.** Whatever you log is read and rendered, never **Your values are never modified.** Whatever you log is read and rendered,
written to. A map or a slice you pass to `slog.Any` comes back from the logger never written to. A map or a slice you pass to `slog.Any` comes back from
exactly as you handed it over, even when a group later uses the same key. the logger exactly as you handed it over, even when a group later uses the
same key.
**Durations are nanoseconds in json, and readable on the console.** The json and **Durations are nanoseconds in json, and readable on the console.** The
webhook payloads emit a `slog.Duration` as a number of nanoseconds, matching json and webhook payloads emit a `slog.Duration` as a number of
`slog.NewJSONHandler`, so a consumer can compare and aggregate the field without nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and
parsing it. The console line emits the same duration as `3s`, matching aggregate the field without parsing it. The console line emits the same
`slog.NewTextHandler`, because a person reads it. duration as `3s`, matching `slog.NewTextHandler`, because a person reads
it.
**The json payload is an object, with the consequences an object has.** The **The json payload is an object, with the consequences an object has.**
record's own fields are named `Time`, `Level`, `Message` and `PC`, and they own The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
those names: an attribute keyed after one of them is dropped from the json and they own those names: an attribute keyed after one of them is dropped from
webhook output. A key logged more than once keeps its last value there for the the json and webhook output. A key logged more than once keeps its last
same reason, with one exception: when both are `slog.Group` values sharing a value there for the same reason, with one exception: when both are
key, the two groups are merged into one object holding the members of both, `slog.Group` values sharing a key, the two groups are merged into one
rather than the second replacing the first. Neither the collision nor the merge object holding the members of both, rather than the second replacing the
applies to the console output, which is a line of text: every pair appears, in first. Neither the collision nor the merge applies to the console output,
order. If you need a field called `message`, pick a key that does not collide - which is a line of text: every pair appears, in order. If you need a field
the collision is silent. 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, **Empty things follow the `slog.Handler` contract.** An empty `Attr` is
an empty group is elided along with its key, a group with an empty key is ignored, an empty group is elided along with its key, a group with an
inlined into its parent, and `WithGroup("")` returns the handler unchanged. empty key is inlined into its parent, and `WithGroup("")` returns the
handler unchanged.
## When delivery fails ## When delivery fails
Every handler returns an error from `Handle` when it could not deliver the Every handler returns an error from `Handle` when it could not deliver
record: the record:
- `ConsoleHandler` and `JSONHandler` return the error from their write to - `ConsoleHandler` and `JSONHandler` return the error from their write to
stdout, wrapped, so `errors.Is` still matches the original stdout, wrapped, so `errors.Is` still matches the original
- `WebhookHandler` returns an error when the request fails, and also when the - `WebhookHandler` returns an error when the request fails, and also when
server answers with a status outside 2xx, a redirect included, since it does the server answers with a status outside 2xx, a redirect included,
not follow redirects. A request that has not finished after 5 seconds fails since it does not follow redirects
with a timeout error, so a webhook server that never answers holds up a log - `MultiplexHandler`, which simplelog installs as the default, passes the
call for 5 seconds at most record to every handler it holds even after one of them fails, then
- `MultiplexHandler`, which simplelog installs as the default, passes the record returns all their errors joined with `errors.Join` (nil if none failed)
to every handler it holds even after one of them fails, then returns all their
errors joined with `errors.Join` (nil if none failed)
`slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that error `slog.Info`, `slog.Error` and the other `slog.Logger` methods throw that
away. That is how `log/slog` works, and simplelog cannot change it. To find out error away. That is how `log/slog` works, and simplelog cannot change it.
whether a record was delivered, build a `slog.Record` and pass it to the handler To find out whether a record was delivered, build a `slog.Record` and pass
yourself, for example `slog.Default().Handler().Handle(ctx, record)`, then check it to the handler yourself, for example
the error it returns. Console output takes the file and line from the record's `slog.Default().Handler().Handle(ctx, record)`, then check the error it
`PC`, which you set with `runtime.Callers` as the `log/slog` documentation returns. Console output takes the file and line from the record's `PC`,
which you set with `runtime.Callers` as the `log/slog` documentation
shows. A record whose `PC` is zero prints `???:0` instead. shows. A record whose `PC` is zero prints `???:0` instead.
## Entrypoints ## Entrypoints
@@ -134,12 +139,9 @@ 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 The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide: alpine. We provide:
- `script/bootstrap` — install all dependencies (go if missing, goimports with - `script/bootstrap` — install all dependencies (go if missing, then
`go install` at a pinned commit unless the installed one already reports the `go mod download`); golangci-lint is not installed, since it runs only in
pinned version, then `go mod download`; node through nvm at a pinned version Docker
if node is missing, yarn at a pinned version if it is missing, then the
`prettier` that `package.json` and `yarn.lock` pin); golangci-lint is not
installed, since it runs only in Docker
- `script/setup` — set up the repo for development after a fresh clone: runs - `script/setup` — set up the repo for development after a fresh clone: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (our own extension); used by - `script/projectname` — output the project name (our own extension); used by
@@ -147,23 +149,23 @@ alpine. We provide:
- `script/test` — run the test suite under the race detector in Docker by - `script/test` — run the test suite under the race detector in Docker by
building only the `test` stage of the `Dockerfile`, without the build cache, building only the `test` stage of the `Dockerfile`, without the build cache,
so every run tests; the image is tagged `simplelog-test` so every run tests; the image is tagged `simplelog-test`
- `script/lint` — run golangci-lint in Docker by building only the `lint` stage - `script/lint` — run golangci-lint in Docker by building only the `lint`
of the `Dockerfile`, without the build cache, so every run lints; the image is stage of the `Dockerfile`, without the build cache, so every run lints; the
tagged `simplelog-lint` image is tagged `simplelog-lint`
- `script/fmt` — format all files (writes): Go with the goimports that - `script/fmt` — format all files with goimports (writes); goimports runs
`script/bootstrap` installs, then every `*.md` file with `prettier` with `go run` at a pinned commit, so nothing needs to install it
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l` reports - `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
files, or if `prettier` would change any `*.md` file reports files
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
extension) extension)
- `script/docker` — build the Docker image without the build cache, tagged via - `script/docker` — build the Docker image without the build cache, tagged
`script/projectname` (byte-identical across repos); it also passes the output via `script/projectname` (byte-identical across repos); it also passes the
of `git describe` as the `VERSION` build argument, which is ignored because output of `git describe` as the `VERSION` build argument, which is ignored
this `Dockerfile` does not declare it because this `Dockerfile` does not declare it
- `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then - `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`,
the same image build as `script/docker` (byte-identical across repos) then the same image build as `script/docker` (byte-identical across repos)
- `script/precommit` — run by the git pre-commit hook (our own extension); runs - `script/precommit` — run by the git pre-commit hook (our own extension);
a `go mod tidy` guard, then `script/check` runs a `go mod tidy` guard, then `script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
+67 -73
View File
@@ -1,89 +1,83 @@
# Workflow # Workflow
- branch (from `main`) * branch (from `main`)
- do the work in Next Step * do the work in Next Step
- move Next Step to the top of Completed Steps * move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step * move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) * commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR * merge to `main` if the branch is not protected, otherwise open a PR
- push * push
# Status # Status
1.0+ 1.0+
Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0 maintenance; the Tagged v1.0.0 (2024-06-14) and 1.0.1 (2026-02-08). In post-1.0
library is referenced by the Go styleguide. maintenance; the library is referenced by the Go styleguide.
# Next Step # Next Step
Restructure README.md into the standard sections: Description, Getting Started, Restructure README.md into the standard sections: Description, Getting
Rationale, Design, TODO, License, Author Started, Rationale, Design, TODO, License, Author
# Completed Steps # Completed Steps
- 2026-10-06: `make fmt` and `make fmt-check` also format Markdown, with * 2026-10-06: re-vendored the standard files from `sneak/prompts` at
`prettier` at the standard settings, pinned through `package.json` and commit `dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`,
`yarn.lock`; `script/bootstrap` installs node and yarn for it, and the and `.golangci.yml` together with the lint stage's new image, plus
existing Markdown was formatted once the `.editorconfig` and `.dockerignore` this repo lacked, which
- 2026-10-06: re-vendored the standard files from `sneak/prompts` at commit finishes the Makefile and policy-files step. `script/cibuild` now
`dd4027b`: `REPO_POLICIES.md`, the CI workflow, `.gitignore`, and bootstraps and runs `script/check` before building the image, the
`.golangci.yml` together with the lint stage's new image, plus the format check runs only on the host, the last `Dockerfile` stage runs
`.editorconfig` and `.dockerignore` this repo lacked, which finishes the `script/bootstrap`, and `script/fmt` runs goimports at a pinned commit
Makefile and policy-files step. `script/cibuild` now bootstraps and runs * 2026-10-06: the console handler takes the file and line it prints from
`script/check` before building the image, the format check runs only on the the record's `PC` instead of counting stack frames, so they name the
host, the last `Dockerfile` stage runs `script/bootstrap`, which installs call site whether the record came through a `slog.Logger` or straight
goimports at a pinned commit to `Handle`
- 2026-10-06: a webhook request now times out after 5 seconds, so a server that * 2026-10-06: the tests run under the race detector, in Docker:
never answers no longer stops every log call; the webhook handler also reads `script/test` builds the `test` stage of the `Dockerfile`, which runs
each answer to the end so its connection is reused `go test -race`, and a new final stage makes a plain `docker build`
- 2026-10-06: the console handler takes the file and line it prints from the run both the `lint` and `test` stages
record's `PC` instead of counting stack frames, so they name the call site * 2026-10-06: the linter runs only in Docker: `script/lint` builds the
whether the record came through a `slog.Logger` or straight to `Handle` `lint` stage of the `Dockerfile`, every `docker build` in `script/`
- 2026-10-06: the tests run under the race detector, in Docker: `script/test` runs without the build cache, and `script/bootstrap` no longer
builds the `test` stage of the `Dockerfile`, which runs `go test -race`, and a installs golangci-lint
new final stage makes a plain `docker build` run both the `lint` and `test` * 2026-10-06: every handler now returns a failed delivery from `Handle`
stages instead of discarding it: console and JSON return the stdout write
- 2026-10-06: the linter runs only in Docker: `script/lint` builds the `lint` error, the webhook also fails on a non-2xx answer, and the multiplex
stage of the `Dockerfile`, every `docker build` in `script/` runs without the delivers to every handler and returns their errors joined
build cache, and `script/bootstrap` no longer installs golangci-lint * 2026-08-10: fixed every handler discarding slog attributes: console,
- 2026-10-06: every handler now returns a failed delivery from `Handle` instead JSON and webhook handlers now emit record attributes, accumulate
of discarding it: console and JSON return the stdout write error, the webhook WithAttrs without mutating the receiver, and honour WithGroup;
also fails on a non-2xx answer, and the multiplex delivers to every handler slog.Group values nest and LogValuer values are resolved; console
and returns their errors joined keys and values holding invalid UTF-8 are quoted
- 2026-08-10: fixed every handler discarding slog attributes: console, JSON and * 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the
webhook handlers now emit record attributes, accumulate WithAttrs without `Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and
mutating the receiver, and honour WithGroup; slog.Group values nest and fixed all findings the v1→v2 jump surfaced without changing any
LogValuer values are resolved; console keys and values holding invalid UTF-8 exported signatures or behavior
are quoted - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
- 2026-08-07: added canonical `.golangci.yml` (v2 schema), pinned the Makefile shims, README Entrypoints section
`Dockerfile` lint stage to golangci-lint v2.12.2 (tag+digest), and fixed all * 2026-02-08: fixed JSONHandler deadlock from recursive log.Println,
findings the v1→v2 jump surfaced without changing any exported signatures or with regression test; tagged 1.0.1
behavior * 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call
stack depth fix so log locations report correctly, example script,
* 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile removed non-building RELP code; tagged v1.0.0
shims, README Entrypoints section * 2024-05-22: module path moved to sneak.berlin/go/simplelog
* 2024-05-14: initial library: slog-based console, JSON, webhook, and
- 2026-02-08: fixed JSONHandler deadlock from recursive log.Println, with RELP handlers, MultiplexHandler, caller file/line info, UTC ISO
regression test; tagged 1.0.1 timestamps, level-colored output
- 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 # Future Steps
- Delete the old plain TODO file once this TODO.md lands * Delete the old plain TODO file once this TODO.md lands
- Add .aider.\* to .gitignore and remove the stray aider artifacts from the * Add .aider.* to .gitignore and remove the stray aider artifacts from
working tree the working tree
- Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are inconsistent) * Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
- Tag v1.0.2, with the leading v, once the attribute fix lands, so consuming inconsistent)
repos can move off pseudo-version pins in one step * Tag v1.0.2, with the leading v, once the attribute fix lands, so
- Fix RELP output to cache (from old TODO) consuming repos can move off pseudo-version pins in one step
- Re-add RELP delivery over TCP to remote rsyslog imrelp; removed 2024-06-14 * Fix RELP output to cache (from old TODO)
because it did not build (README planned feature) * Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
- Add regex filtering for webhook logs (from old TODO) 2024-06-14 because it did not build (README planned feature)
- Better console output format (from old TODO) * Add regex filtering for webhook logs (from old TODO)
* Better console output format (from old TODO)
-101
View File
@@ -5,7 +5,6 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -161,103 +160,3 @@ func TestWebhookHandlerReturnsErrorOnRedirect(t *testing.T) {
t.Fatalf("Handle returned %v, want an error for the redirect", err) t.Fatalf("Handle returned %v, want an error for the redirect", err)
} }
} }
// A server that accepts the request and never answers must not hold up
// the log call: Handle gives up after webhookTimeout and says why.
func TestWebhookHandlerTimesOutOnServerThatNeverAnswers(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, _ *http.Request) {
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
// A server that sends a 2xx status and then never finishes the answer
// must not hold up the log call either: reading the answer counts toward
// webhookTimeout, and Handle returns the read's error.
func TestWebhookHandlerTimesOutOnServerThatStallsTheAnswer(t *testing.T) {
t.Parallel()
testEnded := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
// Flush sends the status now; the answer stays unfinished
// until the test ends.
err := http.NewResponseController(w).Flush()
if err != nil {
t.Errorf("flush the status: %v", err)
}
<-testEnded
},
))
// server.Close waits for running requests, so the server's handler
// is released first.
defer func() {
close(testEnded)
server.Close()
}()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
// Handle runs in a goroutine so that a lost timeout fails the test
// instead of hanging the test run.
handleErr := make(chan error, 1)
go func() {
handleErr <- handler.Handle(context.Background(), errorTestRecord())
}()
select {
case err = <-handleErr:
case <-time.After(webhookTimeout + time.Second):
t.Fatal("Handle did not return within webhookTimeout")
}
var netErr net.Error
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("Handle returned %v, want a timeout error", err)
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"private": true,
"devDependencies": {
"prettier": "3.8.1"
}
}
+2 -113
View File
@@ -2,26 +2,11 @@
# script/bootstrap: install all dependencies needed to build and develop # script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is # or apk (detected in that order); assumes nothing is present.
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
# goimports from golang.org/x/tools v0.30.0, 2025-02-10: the last release
# that Go 1.22, the Go of go.mod and the Dockerfile, can build.
GOIMPORTS_COMMIT="09747cdf594a7924dcecb506312be3bd6e437962"
GOIMPORTS_VERSION="v0.30.0"
PKGMGR="" PKGMGR=""
SUDO="" SUDO=""
@@ -62,98 +47,6 @@ missing() {
! command -v "$1" >/dev/null 2>&1 ! command -v "$1" >/dev/null 2>&1
} }
# Print the golang.org/x/tools version that the goimports on PATH was
# built from, or nothing. goimports has no --version flag; `go version -m`
# prints the module versions Go records in every binary it builds.
goimports_version() {
bin="$(command -v goimports)" || return 0
go version -m "$bin" 2>/dev/null |
awk '$1 == "mod" && $2 == "golang.org/x/tools" { print $3 }'
}
# Install goimports at the pinned commit unless the one on PATH already
# reports the pinned version. go install writes to GOBIN, or to the bin
# directory of GOPATH when GOBIN is unset, which need not be on the
# caller's PATH; script/fmt puts that directory first on PATH the same
# way, so the check here sees the goimports that script/fmt runs.
ensure_goimports() {
gobin="$(go env GOBIN)"
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
go install "golang.org/x/tools/cmd/goimports@$GOIMPORTS_COMMIT"
hash -r
if [ "$(goimports_version)" != "$GOIMPORTS_VERSION" ]; then
echo "bootstrap: goimports on PATH is not $GOIMPORTS_VERSION" \
"after installing it: $(command -v goimports || echo none)" >&2
exit 1
fi
fi
echo "goimports $GOIMPORTS_VERSION at $(command -v goimports)"
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -162,14 +55,10 @@ main() {
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
# golangci-lint is not installed: it runs only in docker (script/lint). # golangci-lint is not installed: it runs only in docker (script/lint).
ensure_goimports # Nor is goimports: script/fmt runs it with `go run` at a pinned commit.
go mod download go mod download
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete" echo "bootstrap complete"
} }
+6 -26
View File
@@ -1,37 +1,17 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes): Go with the goimports that # script/fmt: format all files (writes). goimports runs with `go run` at
# script/bootstrap installs, then Markdown with prettier. It puts Go's bin # a pinned commit, so nothing installs it and every machine formats with
# directory first on PATH, as script/bootstrap does, so it runs that # the same version.
# goimports even when the directory is not on the caller's PATH.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap. # goimports from golang.org/x/tools v0.51.0, 2026-10-02
NODE_VERSION="22.17.0" GOIMPORTS="golang.org/x/tools/cmd/goimports@ea2f152dc35325e4b9b639583972375972350a84"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
gobin="$(go env GOBIN)" go run "$GOIMPORTS" -l -w .
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
goimports -l -w .
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+1 -22
View File
@@ -1,30 +1,10 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Fails and lists the # script/fmt-check: check formatting (read-only). Fails and lists the
# offending files if gofmt would reformat any Go file; otherwise fails if # offending files if gofmt would reformat anything.
# prettier would reformat any Markdown file.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
unformatted="$(gofmt -l .)" unformatted="$(gofmt -l .)"
@@ -33,7 +13,6 @@ main() {
echo "$unformatted" echo "$unformatted"
exit 1 exit 1
fi fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
+1 -19
View File
@@ -6,22 +6,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"time"
) )
// errWebhookStatus is returned when the webhook answers with a status // errWebhookStatus is returned when the webhook answers with a status
// outside 2xx. // outside 2xx.
var errWebhookStatus = errors.New("webhook did not accept the record") var errWebhookStatus = errors.New("webhook did not accept the record")
// webhookTimeout bounds each webhook request, reading the answer
// included. Handle runs inside the log call, so a server that never
// answers would otherwise hold that call up for good.
const webhookTimeout = 5 * time.Second
// WebhookHandler POSTs each log record as JSON to a configured webhook // WebhookHandler POSTs each log record as JSON to a configured webhook
// URL. // URL.
type WebhookHandler struct { type WebhookHandler struct {
@@ -41,7 +34,6 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
return &WebhookHandler{ return &WebhookHandler{
webhookURL: webhookURL, webhookURL: webhookURL,
client: &http.Client{ client: &http.Client{
Timeout: webhookTimeout,
// Following a redirect can resend the request as a GET // Following a redirect can resend the request as a GET
// without the record, so Handle gets the redirect answer // without the record, so Handle gets the redirect answer
// itself and returns it as an error. // itself and returns it as an error.
@@ -88,8 +80,7 @@ func (w *WebhookHandler) WithGroup(name string) slog.Handler {
// Handle marshals the record, with its attributes, to one JSON object and // Handle marshals the record, with its attributes, to one JSON object and
// POSTs it to the webhook URL. It returns an error when the request fails // POSTs it to the webhook URL. It returns an error when the request fails
// or runs past webhookTimeout, or when the server answers with a status // or the server answers with a status outside 2xx.
// outside 2xx.
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error { func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(recordToMap(record, w.attrs)) jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil { if err != nil {
@@ -115,15 +106,6 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
defer func() { _ = response.Body.Close() }() defer func() { _ = response.Body.Close() }()
// The answer is read to the end so the client can reuse the
// connection for the next record. The read counts toward
// webhookTimeout, so a server that sends its status and then stalls
// fails here.
_, err = io.Copy(io.Discard, response.Body)
if err != nil {
return fmt.Errorf("error reading webhook answer: %w", err)
}
if response.StatusCode < http.StatusOK || if response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices { response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status) return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==