Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b34edf12e | ||
|
|
db3373c71d |
@@ -76,3 +76,6 @@
|
|||||||
/cmd/example/example
|
/cmd/example/example
|
||||||
/*.test
|
/*.test
|
||||||
/*.out
|
/*.out
|
||||||
|
|
||||||
|
# aider's files, among them a config file that can hold an API key.
|
||||||
|
**/.aider*
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ the record:
|
|||||||
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
|
- `WebhookHandler` returns an error when the request fails, and also when
|
||||||
the server answers with a status outside 2xx, a redirect included,
|
the server answers with a status outside 2xx, a redirect included,
|
||||||
since it does not follow redirects
|
since it does not follow redirects. A request that has not finished
|
||||||
|
after 5 seconds fails with a timeout error, so a webhook server that
|
||||||
|
never answers holds up a log call for 5 seconds at most
|
||||||
- `MultiplexHandler`, which simplelog installs as the default, passes the
|
- `MultiplexHandler`, which simplelog installs as the default, passes the
|
||||||
record to every handler it holds even after one of them fails, then
|
record to every handler it holds even after one of them fails, then
|
||||||
returns all their errors joined with `errors.Join` (nil if none failed)
|
returns all their errors joined with `errors.Join` (nil if none failed)
|
||||||
@@ -139,9 +141,10 @@ 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, then
|
- `script/bootstrap` — install all dependencies (go if missing, goimports with
|
||||||
`go mod download`); golangci-lint is not installed, since it runs only in
|
`go install` at a pinned commit unless the installed one already reports the
|
||||||
Docker
|
pinned version, then `go mod download`); 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
|
||||||
@@ -152,8 +155,8 @@ alpine. We provide:
|
|||||||
- `script/lint` — run golangci-lint in Docker by building only the `lint`
|
- `script/lint` — run golangci-lint in Docker by building only the `lint`
|
||||||
stage of the `Dockerfile`, without the build cache, so every run lints; the
|
stage of the `Dockerfile`, without the build cache, so every run lints; the
|
||||||
image is tagged `simplelog-lint`
|
image is tagged `simplelog-lint`
|
||||||
- `script/fmt` — format all files with goimports (writes); goimports runs
|
- `script/fmt` — format all files (writes) with the goimports that
|
||||||
with `go run` at a pinned commit, so nothing needs to install it
|
`script/bootstrap` installs
|
||||||
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
|
- `script/fmt-check` — check formatting (read-only); fails if `gofmt -l`
|
||||||
reports files
|
reports files
|
||||||
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
|
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ Started, Rationale, Design, TODO, License, Author
|
|||||||
finishes the Makefile and policy-files step. `script/cibuild` now
|
finishes the Makefile and policy-files step. `script/cibuild` now
|
||||||
bootstraps and runs `script/check` before building the image, the
|
bootstraps and runs `script/check` before building the image, the
|
||||||
format check runs only on the host, the last `Dockerfile` stage runs
|
format check runs only on the host, the last `Dockerfile` stage runs
|
||||||
`script/bootstrap`, and `script/fmt` runs goimports at a pinned commit
|
`script/bootstrap`, which installs goimports at a pinned commit
|
||||||
|
* 2026-10-06: a webhook request now times out after 5 seconds, so a
|
||||||
|
server that never answers no longer stops every log call; the webhook
|
||||||
|
handler also reads each answer to the end so its connection is reused
|
||||||
* 2026-10-06: the console handler takes the file and line it prints from
|
* 2026-10-06: the console handler takes the file and line it prints from
|
||||||
the record's `PC` instead of counting stack frames, so they name the
|
the record's `PC` instead of counting stack frames, so they name the
|
||||||
call site whether the record came through a `slog.Logger` or straight
|
call site whether the record came through a `slog.Logger` or straight
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -160,3 +161,103 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+35
-1
@@ -7,6 +7,11 @@ set -eu
|
|||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# 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=""
|
||||||
|
|
||||||
@@ -47,6 +52,35 @@ 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)"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
@@ -55,7 +89,7 @@ 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).
|
||||||
# Nor is goimports: script/fmt runs it with `go run` at a pinned commit.
|
ensure_goimports
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,17 +1,17 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt: format all files (writes). goimports runs with `go run` at
|
# script/fmt: format all files (writes) with the goimports that
|
||||||
# a pinned commit, so nothing installs it and every machine formats with
|
# script/bootstrap installs. It puts Go's bin directory first on PATH, as
|
||||||
# the same version.
|
# script/bootstrap does, so it runs that 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)"
|
||||||
|
|
||||||
# goimports from golang.org/x/tools v0.51.0, 2026-10-02
|
|
||||||
GOIMPORTS="golang.org/x/tools/cmd/goimports@ea2f152dc35325e4b9b639583972375972350a84"
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
go run "$GOIMPORTS" -l -w .
|
gobin="$(go env GOBIN)"
|
||||||
|
PATH="${gobin:-$(go env GOPATH)/bin}:$PATH"
|
||||||
|
goimports -l -w .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+19
-1
@@ -6,15 +6,22 @@ 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 {
|
||||||
@@ -34,6 +41,7 @@ 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.
|
||||||
@@ -80,7 +88,8 @@ 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 the server answers with a status outside 2xx.
|
// or runs past webhookTimeout, or when the server answers with a status
|
||||||
|
// 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 {
|
||||||
@@ -106,6 +115,15 @@ 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user