From 15d762858d96262c2cbec1eb62ed60e6516094b3 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 14:51:20 +0200 Subject: [PATCH 1/8] T-20356 Move the module to github.com/BetterStackHQ/slog-betterstack Better Stack takes over samber/slog-betterstack as its official Go client. The package name slogbetterstack and the API stay as they are, so upgrading is a change of the import path. - logger.name in every record and the User-Agent now say BetterStackHQ/slog-betterstack. - version.go carries the latest released version instead of a placeholder the release pipeline used to rewrite; the Release workflow bumps it. It says 1.4.4, samber's last release, until the first release from here. - example/ becomes example-project/ like in the other clients and reads the source token and ingesting host from the environment. - handler.go is gofmt-ed. Co-Authored-By: Claude Fable 5.1 --- .github/FUNDING.yml | 1 - .github/workflows/dependabot-automerge.yaml | 24 ---------- .github/workflows/lint.yml | 23 ---------- .github/workflows/test.yml | 47 ------------------- example-project/main.go | 51 +++++++++++++++++++++ example/example.go | 27 ----------- go.mod | 2 +- handler.go | 2 +- version.go | 9 +++- 9 files changed, 60 insertions(+), 126 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .github/workflows/dependabot-automerge.yaml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/test.yml create mode 100644 example-project/main.go delete mode 100644 example/example.go diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index e4e0d3c..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [samber] diff --git a/.github/workflows/dependabot-automerge.yaml b/.github/workflows/dependabot-automerge.yaml deleted file mode 100644 index caf7f66..0000000 --- a/.github/workflows/dependabot-automerge.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: Dependabot automerge - -on: - pull_request: - types: [opened, synchronize] - -jobs: - automerge: - runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' - permissions: - contents: write - pull-requests: write - steps: - - name: Fetch Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v3 - - - name: Enable auto-merge for github-actions updates - if: steps.metadata.outputs.package-ecosystem == 'github_actions' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index dd5bd6e..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Lint - -on: - push: - pull_request: - -jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - uses: actions/setup-go@v7 - with: - go-version: 1.21 - stable: false - - uses: actions/checkout@v7 - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - args: --timeout 120s --max-same-issues 50 - - - name: Bearer - uses: bearer/bearer-action@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index cccf7be..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Tests - -on: - push: - tags: - branches: - pull_request: - -jobs: - - test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go: - - '1.21' - - '1.22' - - '1.23' - - '1.24' - - '1.x' - steps: - - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version: ${{ matrix.go }} - stable: false - - - name: Build - run: make build - - - name: Test - run: make test - - - name: Test - run: make coverage - - - name: Codecov - uses: codecov/codecov-action@v7 - with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./cover.out - flags: unittests - verbose: true - if: matrix.go == '1.21' diff --git a/example-project/main.go b/example-project/main.go new file mode 100644 index 0000000..ee75632 --- /dev/null +++ b/example-project/main.go @@ -0,0 +1,51 @@ +// A small program that sends a few log records to Better Stack. +// +// Run it with the source token and the ingesting host of your source, both shown in +// Better Stack → Sources: +// +// BETTERSTACK_SOURCE_TOKEN=... BETTERSTACK_INGESTING_HOST=... go run ./example-project +package main + +import ( + "fmt" + "log/slog" + "os" + "time" + + slogbetterstack "github.com/BetterStackHQ/slog-betterstack" +) + +func main() { + token := os.Getenv("BETTERSTACK_SOURCE_TOKEN") + if token == "" { + fmt.Fprintln(os.Stderr, "set BETTERSTACK_SOURCE_TOKEN to the source token from Better Stack → Sources") + os.Exit(1) + } + + option := slogbetterstack.Option{Level: slog.LevelDebug, Token: token} + if host := os.Getenv("BETTERSTACK_INGESTING_HOST"); host != "" { + option.Endpoint = "https://" + host + "/" + } + + logger := slog.New(option.NewBetterstackHandler()) + logger = logger.With("release", "v1.0.0") + + logger.Debug("Debugging user service.", "service", "UserService") + + logger.With("userID", 123).Error("Unable to fetch user data.") + + logger. + With( + slog.Group("user", + slog.String("id", "user-123"), + slog.Time("created_at", time.Now()), + ), + ). + With("error", fmt.Errorf("an error")). + Error("a message", slog.Int("count", 1)) + + // Logs are sent asynchronously: give the handler a moment before the process exits. + time.Sleep(5 * time.Second) + + fmt.Println("Sent 3 log records. Open Better Stack → Live tail to see them.") +} diff --git a/example/example.go b/example/example.go deleted file mode 100644 index 0c11a2e..0000000 --- a/example/example.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "fmt" - "time" - - slogbetterstack "github.com/samber/slog-betterstack" - - "log/slog" -) - -func main() { - logger := slog.New(slogbetterstack.Option{Level: slog.LevelDebug, Token: "xxxx"}.NewBetterstackHandler()) - logger = logger.With("release", "v1.0.0") - - logger. - With( - slog.Group("user", - slog.String("id", "user-123"), - slog.Time("created_at", time.Now()), - ), - ). - With("error", fmt.Errorf("an error")). - Error("a message", slog.Int("count", 1)) - - time.Sleep(1 * time.Second) -} diff --git a/go.mod b/go.mod index 087c349..3417a8b 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/samber/slog-betterstack +module github.com/BetterStackHQ/slog-betterstack go 1.21 diff --git a/handler.go b/handler.go index f72c271..d82ed82 100644 --- a/handler.go +++ b/handler.go @@ -19,7 +19,7 @@ type Option struct { Level slog.Leveler // token - Token string + Token string // optional: endpoint Endpoint string // default: 10s diff --git a/version.go b/version.go index 8755b19..a875fa9 100644 --- a/version.go +++ b/version.go @@ -1,4 +1,9 @@ package slogbetterstack -const name = "samber/slog-betterstack" -const version = "VERSION" // replaced by .github/workflows/release.yml +// name identifies this library in the User-Agent header and in the "logger.name" field of every +// record. It stays "/", the convention the library has always used. +const name = "BetterStackHQ/slog-betterstack" + +// version is the latest released version and is sent in the "logger.version" field of every +// record. The Release workflow bumps it and tags the commit, so edit it only there. +const version = "1.4.4" From 48d0064db54d64eb9c8011ebe4825cda06912e07 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 14:51:20 +0200 Subject: [PATCH 2/8] T-20356 Pin the record shape, headers and options in tests Applications and Better Stack rely on the shape of the records this handler sends: the dt, level, message, logger.name, logger.version and extra keys, errors expanded under extra as kind/error/stack, the call site under extra.runtime, and the request headers. The tests pin all of that and what every Option field and package setting does, looking only at what reaches the server so the transport can change underneath them. Co-Authored-By: Claude Fable 5.1 --- handler_test.go | 347 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 handler_test.go diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..8746399 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,347 @@ +package slogbetterstack + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "testing" + "time" +) + +// These tests pin the behaviour applications rely on: the shape of the records that reach +// Better Stack, the request headers, and what every option does. The transport may change +// underneath them, so they only ever look at what arrives at the server, never at how. + +type request struct { + header http.Header + records []map[string]any +} + +// newServer stands in for Better Stack. It reports every request it receives with the records +// decoded from its JSON array body. +func newServer(t *testing.T) (*httptest.Server, <-chan request) { + t.Helper() + requests := make(chan request, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + var records []map[string]any + if err := json.Unmarshal(body, &records); err != nil { + t.Errorf("body is not a JSON array of records: %v\n%s", err, body) + return + } + requests <- request{header: r.Header.Clone(), records: records} + })) + t.Cleanup(func() { + server.Close() + // The handler sends through http.DefaultTransport; drop its idle connections so their + // goroutines are gone before goleak looks. + http.DefaultTransport.(*http.Transport).CloseIdleConnections() + }) + return server, requests +} + +func receive(t *testing.T, requests <-chan request) request { + t.Helper() + select { + case r := <-requests: + return r + case <-time.After(5 * time.Second): + t.Fatal("no request reached the server within 5s") + return request{} + } +} + +func oneRecord(t *testing.T, requests <-chan request) map[string]any { + t.Helper() + r := receive(t, requests) + if len(r.records) != 1 { + t.Fatalf("got %d records in one request, want 1: %v", len(r.records), r.records) + } + return r.records[0] +} + +func extraOf(t *testing.T, record map[string]any) map[string]any { + t.Helper() + extra, ok := record["extra"].(map[string]any) + if !ok { + t.Fatalf(`record has no "extra" object: %v`, record) + } + return extra +} + +func newLogger(server *httptest.Server, option Option) *slog.Logger { + option.Token = "test-token" + option.Endpoint = server.URL + return slog.New(option.NewBetterstackHandler()) +} + +func TestRecordShape(t *testing.T) { + server, requests := newServer(t) + logger := newLogger(server, Option{}).With("release", "v1.0.0") + + before := time.Now() + logger. + With( + slog.Group("user", + slog.String("id", "user-123"), + slog.Time("created_at", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)), + ), + ). + With("error", errors.New("an error")). + Error("a message", slog.Int("count", 1)) + + record := oneRecord(t, requests) + + var keys []string + for key := range record { + keys = append(keys, key) + } + sort.Strings(keys) + wantKeys := []string{"dt", "extra", "level", "logger.name", "logger.version", "message"} + if !reflect.DeepEqual(keys, wantKeys) { + t.Errorf("record keys = %v, want %v", keys, wantKeys) + } + + if record["level"] != "ERROR" { + t.Errorf(`level = %v, want "ERROR"`, record["level"]) + } + if record["message"] != "a message" { + t.Errorf(`message = %v, want "a message"`, record["message"]) + } + if record["logger.name"] != "BetterStackHQ/slog-betterstack" { + t.Errorf(`logger.name = %v, want "BetterStackHQ/slog-betterstack"`, record["logger.name"]) + } + if record["logger.version"] != version { + t.Errorf("logger.version = %v, want %q", record["logger.version"], version) + } + + dt, ok := record["dt"].(string) + if !ok || !strings.HasSuffix(dt, "Z") { + t.Fatalf("dt = %v, want an RFC 3339 UTC timestamp", record["dt"]) + } + parsed, err := time.Parse(time.RFC3339Nano, dt) + if err != nil { + t.Fatalf("dt %q does not parse as RFC 3339: %v", dt, err) + } + if parsed.Before(before.Add(-time.Second)) || parsed.After(time.Now().Add(time.Second)) { + t.Errorf("dt = %s, want the time of the log call", dt) + } + + wantExtra := map[string]any{ + "release": "v1.0.0", + "count": float64(1), + "user": map[string]any{ + "id": "user-123", + "created_at": "2026-01-02T03:04:05Z", + }, + "error": map[string]any{ + "kind": "*errors.errorString", + "error": "an error", + "stack": nil, + }, + } + if extra := extraOf(t, record); !reflect.DeepEqual(extra, wantExtra) { + t.Errorf("extra = %#v, want %#v", extra, wantExtra) + } +} + +func TestRequestHeaders(t *testing.T) { + server, requests := newServer(t) + logger := newLogger(server, Option{}) + + logger.Info("hello") + + got := receive(t, requests) + for header, want := range map[string]string{ + "Authorization": "Bearer test-token", + "Content-Type": "application/json", + "User-Agent": "BetterStackHQ/slog-betterstack", + } { + if value := got.header.Get(header); value != want { + t.Errorf("%s = %q, want %q", header, value, want) + } + } + if len(got.records) != 1 { + t.Fatalf("got %d records, want 1", len(got.records)) + } + if extra := extraOf(t, got.records[0]); len(extra) != 0 { + t.Errorf("extra = %v, want an empty object for a record without attributes", extra) + } +} + +func TestDefaults(t *testing.T) { + handler, ok := Option{Token: "x"}.NewBetterstackHandler().(*BetterstackHandler) + if !ok { + t.Fatal("NewBetterstackHandler does not return a *BetterstackHandler") + } + if handler.option.Endpoint != "https://in.logs.betterstack.com/" { + t.Errorf("Endpoint = %q, want the Better Stack ingesting endpoint", handler.option.Endpoint) + } + if handler.option.Timeout != 10*time.Second { + t.Errorf("Timeout = %s, want 10s", handler.option.Timeout) + } + if handler.option.Level.Level() != slog.LevelDebug { + t.Errorf("Level = %s, want DEBUG", handler.option.Level.Level()) + } +} + +func TestMissingTokenPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("NewBetterstackHandler without a token did not panic") + } + }() + Option{}.NewBetterstackHandler() +} + +func TestLevel(t *testing.T) { + ctx := context.Background() + + if !(Option{Token: "x"}).NewBetterstackHandler().Enabled(ctx, slog.LevelDebug) { + t.Error("debug records are disabled by default, want enabled") + } + + handler := Option{Token: "x", Level: slog.LevelWarn}.NewBetterstackHandler() + if handler.Enabled(ctx, slog.LevelInfo) { + t.Error("info is enabled with Level: WARN") + } + if !handler.Enabled(ctx, slog.LevelWarn) { + t.Error("warn is disabled with Level: WARN") + } +} + +func TestWithGroupNestsAttributes(t *testing.T) { + server, requests := newServer(t) + logger := newLogger(server, Option{}).WithGroup("request") + + logger.Info("handled", "id", "r-1", slog.Group("response", "status", 200)) + + want := map[string]any{ + "request": map[string]any{ + "id": "r-1", + "response": map[string]any{"status": float64(200)}, + }, + } + if extra := extraOf(t, oneRecord(t, requests)); !reflect.DeepEqual(extra, want) { + t.Errorf("extra = %#v, want %#v", extra, want) + } +} + +func TestAddSource(t *testing.T) { + server, requests := newServer(t) + logger := newLogger(server, Option{AddSource: true}) + + logger.Info("where am I") + + runtime, ok := extraOf(t, oneRecord(t, requests))["runtime"].(map[string]any) + if !ok { + t.Fatal(`AddSource did not add an "extra.runtime" object`) + } + if function, _ := runtime["function"].(string); !strings.HasSuffix(function, ".TestAddSource") { + t.Errorf("runtime.function = %v, want the calling test function", runtime["function"]) + } + if file, _ := runtime["file"].(string); !strings.HasSuffix(file, "handler_test.go") { + t.Errorf("runtime.file = %v, want this file", runtime["file"]) + } + if line, _ := runtime["line"].(float64); line <= 0 { + t.Errorf("runtime.line = %v, want a line number", runtime["line"]) + } +} + +func TestReplaceAttr(t *testing.T) { + server, requests := newServer(t) + logger := newLogger(server, Option{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == "password" { + return slog.String("password", "[redacted]") + } + return a + }, + }) + + logger.Info("login", "user", "alice", "password", "hunter2") + + want := map[string]any{"user": "alice", "password": "[redacted]"} + if extra := extraOf(t, oneRecord(t, requests)); !reflect.DeepEqual(extra, want) { + t.Errorf("extra = %#v, want %#v", extra, want) + } +} + +func TestAttrFromContext(t *testing.T) { + type key struct{} + server, requests := newServer(t) + logger := newLogger(server, Option{ + AttrFromContext: []func(ctx context.Context) []slog.Attr{ + func(ctx context.Context) []slog.Attr { + return []slog.Attr{slog.String("request_id", ctx.Value(key{}).(string))} + }, + }, + }) + + logger.InfoContext(context.WithValue(context.Background(), key{}, "req-42"), "handled") + + want := map[string]any{"request_id": "req-42"} + if extra := extraOf(t, oneRecord(t, requests)); !reflect.DeepEqual(extra, want) { + t.Errorf("extra = %#v, want %#v", extra, want) + } +} + +func TestContextKey(t *testing.T) { + previous := ContextKey + ContextKey = "context" + t.Cleanup(func() { ContextKey = previous }) + + server, requests := newServer(t) + logger := newLogger(server, Option{}) + + logger.Info("hello", "a", 1) + + record := oneRecord(t, requests) + if _, present := record["extra"]; present { + t.Error(`record still has "extra" after ContextKey was changed`) + } + want := map[string]any{"a": float64(1)} + if got := record["context"]; !reflect.DeepEqual(got, want) { + t.Errorf("context = %#v, want %#v", got, want) + } +} + +func TestConverterAndMarshaler(t *testing.T) { + server, requests := newServer(t) + marshaled := make(chan any, 1) + logger := newLogger(server, Option{ + Converter: func(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record) map[string]any { + return map[string]any{"custom": record.Message} + }, + Marshaler: func(v any) ([]byte, error) { + marshaled <- v + return json.Marshal(v) + }, + }) + + logger.Info("shaped elsewhere") + + want := map[string]any{"custom": "shaped elsewhere"} + if record := oneRecord(t, requests); !reflect.DeepEqual(record, want) { + t.Errorf("record = %#v, want the Converter's payload %#v", record, want) + } + select { + case v := <-marshaled: + if _, ok := v.([]map[string]any); !ok { + t.Errorf("Marshaler received %T, want the []map[string]any of records", v) + } + default: + t.Error("Marshaler was not called") + } +} From 68f8a35cb63ff9be8cfead3bddd37adc56040dd4 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 14:51:20 +0200 Subject: [PATCH 3/8] T-20356 Add the CI and Release workflows main.yml builds, vets and runs the tests with the race detector on every Go from 1.21 to stable, on pushes, pull requests, weekly and on demand, and checks gofmt, go mod tidy and golangci-lint. release.yml is the Ruby and Python clients' Release workflow ported to a Go module: patch, minor and retry runs plus dry runs, the version bump in version.go, the version commit and annotated tag pushed by the workflow, a GitHub release with generated notes, and a go list -m through proxy.golang.org so the new version is cached and indexed right away. Every push that touches the file is a dry run. samber's test, lint and Dependabot automerge workflows and FUNDING.yml go; Dependabot itself stays on its monthly schedule. The Makefile keeps the targets the workflows use. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/main.yml | 63 +++++++++ .github/workflows/release.yml | 237 +++++++++++++++++++++++++++------- Makefile | 44 ++----- 3 files changed, 263 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..6d80d92 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,63 @@ +name: build + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '20 5 * * 1' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + + matrix: + go-version: + - "stable" + - "1.25" + - "1.24" + - "1.23" + - "1.22" + - "1.21" + + steps: + - uses: actions/checkout@v7 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.go-version }} + check-latest: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Run tests + run: go test -race -count=1 -v ./... + + lint: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: stable + + - name: gofmt + run: make fmt + + - name: go.mod and go.sum are tidy + run: go mod tidy && git diff --exit-code go.mod go.sum + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49be178..01033fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,59 +1,198 @@ +# Releases this Go module from GitHub Actions, the way the other Better Stack client libraries +# release (logtail-js, logtail-python, logtail-ruby). A Go module has no registry to upload to: a +# release is a "vX.Y.Z" tag on this repository, which the Go module proxy (proxy.golang.org) and +# pkg.go.dev pick up the first time somebody asks for that version. The workflow asks for it +# itself, so the version is cached and indexed right after the release rather than on the first +# user's `go get`. +# +# Repository settings this relies on: the GitHub Actions app must be able to push to main, either +# because main is unprotected or because the app is among the actors allowed to bypass the pull +# request requirement, since the version commit and the tag are pushed by the workflow. +# +# Release: Actions → Release → Run workflow from main, pick patch or minor. The workflow runs the +# tests, bumps the version in version.go, builds, commits "vX.Y.Z", tags it, pushes both, creates a +# GitHub release with auto-generated notes for the tag and fetches the version through the Go +# module proxy; edit the notes afterwards if needed. +# +# Retry: if a release failed after the version commit was pushed, run "retry" from main. It bumps +# nothing, checks out the tag of the version in version.go and finishes the release from there: the +# GitHub release is created if it is still missing and the proxy is asked again. Running patch or +# minor again would release the next version instead. +# +# Dry run: bumps in place without committing and builds; nothing is pushed or published. Every push +# that touches this file is a dry run, so a change to the workflow proves itself on its pull request +# before it reaches main. A dry run can also be dispatched from any branch. name: Release on: workflow_dispatch: inputs: - semver: - type: string - description: 'Semver (eg: v1.2.3)' + release: + description: "patch or minor: bump, tag and publish. retry: finish a release that failed halfway." + type: choice + options: [patch, minor, retry] required: true + dry_run: + description: "Dry run: bump and build, publish nothing" + type: boolean + default: false + push: + paths: + - .github/workflows/release.yml + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +env: + DRY_RUN: ${{ github.event_name == 'push' || inputs.dry_run }} + RELEASE: ${{ inputs.release || 'patch' }} + MODULE: github.com/BetterStackHQ/slog-betterstack jobs: + verify: + name: Test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: stable + + - name: Run tests + run: | + go vet ./... + go test -race -count=1 ./... + + build: + name: Bump and tag + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: write # pushes the version commit and the tag + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Releases run from main only + if: ${{ env.DRY_RUN != 'true' && github.ref != 'refs/heads/main' }} + run: | + echo "::error::Dispatch the release from main, not from $GITHUB_REF_NAME." + exit 1 + + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the version check looks at the tags on HEAD + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: stable + + - name: Bump version + id: version + run: | + python3 - <<'EOF' + import os, re, subprocess, sys + + release, dry_run = os.environ["RELEASE"], os.environ.get("DRY_RUN") == "true" + path = "version.go" + source = open(path).read() + match = re.search(r'const version = "(\d+\.\d+\.\d+)"', source) + if not match: + sys.exit(f"no version constant in {path}") + current = match.group(1) + tags = subprocess.run(["git", "tag", "--points-at", "HEAD"], capture_output=True, text=True, check=True).stdout.split() + tagged = f"v{current}" in tags + + if release == "retry": + # main may have moved on since the version commit, so build what the tag points at + exists = subprocess.run(["git", "rev-parse", "-q", "--verify", f"refs/tags/v{current}"], capture_output=True).returncode == 0 + if not exists: + sys.exit(f"retry only finishes a release whose version v{current} is tagged") + subprocess.run(["git", "checkout", "-q", f"v{current}"], check=True) + version = current + else: + if tagged and not dry_run: + sys.exit(f"HEAD is already released as v{current}, there is nothing new to release") + major, minor, patch = map(int, current.split(".")) + version = f"{major}.{minor + 1}.0" if release == "minor" else f"{major}.{minor}.{patch + 1}" + open(path, "w").write(source.replace(f'"{current}"', f'"{version}"', 1)) + + print(f"{current} -> {version}") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"version={version}\n") + EOF + + - name: Build + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git diff --stat + grep -q "const version = \"$VERSION\"" version.go || { echo "::error::version.go does not carry $VERSION."; exit 1; } + go build ./... + go vet ./... + make fmt + + - name: Commit and tag + if: ${{ env.DRY_RUN != 'true' && env.RELEASE != 'retry' }} + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -am "v$VERSION" + git tag -a "v$VERSION" -m "v$VERSION" + git push origin main "v$VERSION" + release: - if: github.triggering_actor == 'samber' - runs-on: ubuntu-latest + name: Publish + needs: build + # Dry runs from other branches stop after the build; on main they only report what would happen + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: ubuntu-24.04 + permissions: + contents: write # creates the GitHub release + env: + VERSION: ${{ needs.build.outputs.version }} + steps: - - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version: 1.21 - stable: false - - - name: Test - run: make test - - # remove tests in order to clean dependencies - - name: Remove xxx_test.go files - run: rm -rf *_test.go ./examples ./images - - - name: Set library version in version.go - run: sed -i 's/VERSION/${{ inputs.semver }}/g' version.go - - # cleanup test dependencies - - name: Cleanup dependencies - run: go mod tidy - - - name: List files - run: tree -Cfi - - name: Write new go.mod into logs - run: cat go.mod - - name: Write new go.sum into logs - run: cat go.sum - - - name: Create tag - run: | - git config --global user.name '${{ github.triggering_actor }}' - git config --global user.email "${{ github.triggering_actor}}@users.noreply.github.com" - - git add . - git commit --allow-empty -m 'bump ${{ inputs.semver }}' - git tag ${{ inputs.semver }} - git push origin ${{ inputs.semver }} - - - name: Release - uses: softprops/action-gh-release@v3 - with: - name: ${{ inputs.semver }} - tag_name: ${{ inputs.semver }} + - name: Dry run + if: ${{ env.DRY_RUN == 'true' }} + run: echo "Would tag v$VERSION, create its GitHub release and fetch $MODULE@v$VERSION through the Go module proxy. Nothing is published." + + - name: Create GitHub release + if: ${{ env.DRY_RUN != 'true' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + if gh release view "v$VERSION" > /dev/null 2>&1; then + echo "Release v$VERSION already exists." + else + # --verify-tag: only ever attach to the tag the build job pushed, never create one here. + gh release create "v$VERSION" --verify-tag --generate-notes + fi + + - name: Set up Go + if: ${{ env.DRY_RUN != 'true' }} + uses: actions/setup-go@v7 + with: + go-version: stable + + # Asking the proxy for the version makes it fetch the tag from GitHub, record it in the + # checksum database and hand it to pkg.go.dev, so `go get` works right away for everybody. + - name: Publish to the Go module proxy + if: ${{ env.DRY_RUN != 'true' }} + env: + GOPROXY: https://proxy.golang.org + GOFLAGS: -mod=mod + run: | + cd "$(mktemp -d)" + go mod init proxycheck > /dev/null 2>&1 + go list -m -json "$MODULE@v$VERSION" diff --git a/Makefile b/Makefile index accd04f..c292e82 100644 --- a/Makefile +++ b/Makefile @@ -1,41 +1,21 @@ +.PHONY: build test vet fmt lint coverage build: - go build -v ./... + go build ./... test: - go test -race -v ./... -watch-test: - reflex -t 50ms -s -- sh -c 'gotest -race -v ./...' + go test -race -count=1 ./... -bench: - go test -benchmem -count 3 -bench ./... -watch-bench: - reflex -t 50ms -s -- sh -c 'go test -benchmem -count 3 -bench ./...' +vet: + go vet ./... -coverage: - go test -v -coverprofile=cover.out -covermode=atomic ./... - go tool cover -html=cover.out -o cover.html - -tools: - go install github.com/cespare/reflex@latest - go install github.com/rakyll/gotest@latest - go install github.com/psampaz/go-mod-outdated@latest - go install github.com/jondot/goweight@latest - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - go get -t -u golang.org/x/tools/cmd/cover - go install github.com/sonatype-nexus-community/nancy@latest - go mod tidy +# Fails when any file is not gofmt-formatted. +fmt: + @unformatted="$$(gofmt -l .)"; if [ -n "$$unformatted" ]; then echo "gofmt needed:"; echo "$$unformatted"; exit 1; fi lint: - golangci-lint run --timeout 60s --max-same-issues 50 ./... -lint-fix: - golangci-lint run --timeout 60s --max-same-issues 50 --fix ./... - -audit: - go list -json -m all | nancy sleuth + golangci-lint run ./... -outdated: - go list -u -m -json all | go-mod-outdated -update -direct - -weight: - goweight +coverage: + go test -race -count=1 -coverprofile=cover.out -covermode=atomic ./... + go tool cover -html=cover.out -o cover.html From c545bf2af96cb4cbcacbc2bde8417f5cd52e93c0 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 14:51:20 +0200 Subject: [PATCH 4/8] T-20356 README and LICENSE for the official Go client The README follows the other Better Stack clients: install, usage, the record shape, the upgrade path from samber/slog-betterstack and credits to Samuel Berthe. The sponsor banners and the catalogue of unrelated handlers are gone. LICENSE keeps Samuel's MIT copyright line and adds Better Stack's. Co-Authored-By: Claude Fable 5.1 --- LICENSE | 1 + README.md | 309 +++++++++++++++++++++++------------------------------- 2 files changed, 130 insertions(+), 180 deletions(-) diff --git a/LICENSE b/LICENSE index d9ca615..de3b169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2024 Samuel Berthe +Copyright (c) 2026 Better Stack, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 362a8c3..cd7bab7 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,176 @@ +# [Better Stack](https://betterstack.com/logs) Go client -# slog: Betterstack handler - -[![tag](https://img.shields.io/github/tag/samber/slog-betterstack.svg)](https://github.com/samber/slog-betterstack/releases) -![Go Version](https://img.shields.io/badge/Go-%3E%3D%201.21-%23007d9c) -[![GoDoc](https://godoc.org/github.com/samber/slog-betterstack?status.svg)](https://pkg.go.dev/github.com/samber/slog-betterstack) -![Build Status](https://github.com/samber/slog-betterstack/actions/workflows/test.yml/badge.svg) -[![Go report](https://goreportcard.com/badge/github.com/samber/slog-betterstack)](https://goreportcard.com/report/github.com/samber/slog-betterstack) -[![Coverage](https://img.shields.io/codecov/c/github/samber/slog-betterstack)](https://codecov.io/gh/samber/slog-betterstack) -[![Contributors](https://img.shields.io/github/contributors/samber/slog-betterstack)](https://github.com/samber/slog-betterstack/graphs/contributors) -[![License](https://img.shields.io/github/license/samber/slog-betterstack)](./LICENSE) - -A [Betterstack](https://betterstack.com) Handler for [slog](https://pkg.go.dev/log/slog) Go library. - - - -**See also:** - -- [slog-multi](https://github.com/samber/slog-multi): `slog.Handler` chaining, fanout, routing, failover, load balancing... -- [slog-formatter](https://github.com/samber/slog-formatter): `slog` attribute formatting -- [slog-sampling](https://github.com/samber/slog-sampling): `slog` sampling policy -- [slog-mock](https://github.com/samber/slog-mock): `slog.Handler` for test purposes - -**HTTP middlewares:** - -- [slog-gin](https://github.com/samber/slog-gin): Gin middleware for `slog` logger -- [slog-echo](https://github.com/samber/slog-echo): Echo middleware for `slog` logger -- [slog-fiber](https://github.com/samber/slog-fiber): Fiber middleware for `slog` logger -- [slog-chi](https://github.com/samber/slog-chi): Chi middleware for `slog` logger -- [slog-http](https://github.com/samber/slog-http): `net/http` middleware for `slog` logger - -**Loggers:** - -- [slog-zap](https://github.com/samber/slog-zap): A `slog` handler for `Zap` -- [slog-zerolog](https://github.com/samber/slog-zerolog): A `slog` handler for `Zerolog` -- [slog-logrus](https://github.com/samber/slog-logrus): A `slog` handler for `Logrus` - -**Log sinks:** - -- [slog-datadog](https://github.com/samber/slog-datadog): A `slog` handler for `Datadog` -- [slog-betterstack](https://github.com/samber/slog-betterstack): A `slog` handler for `Betterstack` -- [slog-rollbar](https://github.com/samber/slog-rollbar): A `slog` handler for `Rollbar` -- [slog-loki](https://github.com/samber/slog-loki): A `slog` handler for `Loki` -- [slog-sentry](https://github.com/samber/slog-sentry): A `slog` handler for `Sentry` -- [slog-syslog](https://github.com/samber/slog-syslog): A `slog` handler for `Syslog` -- [slog-logstash](https://github.com/samber/slog-logstash): A `slog` handler for `Logstash` -- [slog-fluentd](https://github.com/samber/slog-fluentd): A `slog` handler for `Fluentd` -- [slog-graylog](https://github.com/samber/slog-graylog): A `slog` handler for `Graylog` -- [slog-quickwit](https://github.com/samber/slog-quickwit): A `slog` handler for `Quickwit` -- [slog-slack](https://github.com/samber/slog-slack): A `slog` handler for `Slack` -- [slog-telegram](https://github.com/samber/slog-telegram): A `slog` handler for `Telegram` -- [slog-mattermost](https://github.com/samber/slog-mattermost): A `slog` handler for `Mattermost` -- [slog-microsoft-teams](https://github.com/samber/slog-microsoft-teams): A `slog` handler for `Microsoft Teams` -- [slog-webhook](https://github.com/samber/slog-webhook): A `slog` handler for `Webhook` -- [slog-kafka](https://github.com/samber/slog-kafka): A `slog` handler for `Kafka` -- [slog-nats](https://github.com/samber/slog-nats): A `slog` handler for `NATS` -- [slog-parquet](https://github.com/samber/slog-parquet): A `slog` handler for `Parquet` + `Object Storage` -- [slog-channel](https://github.com/samber/slog-channel): A `slog` handler for Go channels - -## 🚀 Install +[![Better Stack dashboard](https://github.com/logtail/logtail-python/assets/10132717/e2a1196b-7924-4abc-9b85-055e17b5d499)](https://betterstack.com/logs) + +[![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Go Reference](https://pkg.go.dev/badge/github.com/BetterStackHQ/slog-betterstack.svg)](https://pkg.go.dev/github.com/BetterStackHQ/slog-betterstack) +[![build](https://github.com/BetterStackHQ/slog-betterstack/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/BetterStackHQ/slog-betterstack/actions/workflows/main.yml) + +Experience SQL-compatible structured log management based on ClickHouse. [Learn more ⇗](https://betterstack.com/logs) + +A [`log/slog`](https://pkg.go.dev/log/slog) handler that sends the logs of your Go application to Better Stack. + +## Documentation + +[Getting started ⇗](https://betterstack.com/docs/logs/go/) + +## Install ```sh -go get github.com/samber/slog-betterstack +go get github.com/BetterStackHQ/slog-betterstack ``` -**Compatibility**: go >= 1.21 +Go 1.21 or newer. -No breaking changes will be made to exported APIs before v2.0.0. +## Usage -## 💡 Usage +```go +package main -GoDoc: [https://pkg.go.dev/github.com/samber/slog-betterstack](https://pkg.go.dev/github.com/samber/slog-betterstack) +import ( + "log/slog" -### Handler options + slogbetterstack "github.com/BetterStackHQ/slog-betterstack" +) -```go -type Option struct { - // log level (default: debug) - Level slog.Leveler - - // token - Token string - // optional: endpoint - Endpoint string - // default: 10s - Timeout time.Duration - - // optional: customize record builder - Converter Converter - // optional: custom marshaler - Marshaler func(v any) ([]byte, error) - // optional: fetch attributes from context - AttrFromContext []func(ctx context.Context) []slog.Attr - - // optional: see slog.HandlerOptions - AddSource bool - ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +func main() { + logger := slog.New( + slogbetterstack.Option{ + Token: "$SOURCE_TOKEN", + Endpoint: "https://$INGESTING_HOST/", + }.NewBetterstackHandler(), + ) + + logger.Info("Hello from Better Stack!", "service", "UserService") } ``` -Other global parameters: +Find the source token and the ingesting host of your source in Better Stack → Sources. + +Logs are sent asynchronously. For short-lived programs, such as CLI tools or one-off scripts, give +the handler a moment to finish sending before the process exits, for example with +`time.Sleep(5 * time.Second)` after the last log line. Long-running services are not affected. + +### Options ```go -slogbetterstack.SourceKey = "runtime" -slogbetterstack.ContextKey = "context" -slogbetterstack.ErrorKeys = []string{"error", "err"} +type Option struct { + // log level (default: debug) + Level slog.Leveler + + // token + Token string + // optional: endpoint + Endpoint string + // default: 10s + Timeout time.Duration + + // optional: customize record builder + Converter Converter + // optional: custom marshaler + Marshaler func(v any) ([]byte, error) + // optional: fetch attributes from context + AttrFromContext []func(ctx context.Context) []slog.Attr + + // optional: see slog.HandlerOptions + AddSource bool + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} ``` -### Example +Package-level settings: ```go -import ( - "fmt" - "net/http" - "time" +slogbetterstack.SourceKey = "runtime" // where AddSource puts the call site +slogbetterstack.ContextKey = "extra" // where attributes are nested +slogbetterstack.ErrorKeys = []string{"error", "err"} // attributes expanded as errors +``` - slogbetterstack "github.com/samber/slog-betterstack" +### Structured data - "log/slog" -) +```go +logger := slog.New(slogbetterstack.Option{Level: slog.LevelDebug, Token: "$SOURCE_TOKEN"}.NewBetterstackHandler()) +logger = logger.With("release", "v1.0.0") + +logger. + With( + slog.Group("user", + slog.String("id", "user-123"), + slog.Time("created_at", time.Now()), + ), + ). + With("error", fmt.Errorf("an error")). + Error("a message", slog.Int("count", 1)) +``` -func main() { - logger := slog.New(slogbetterstack.Option{Level: slog.LevelDebug, Token: "xxxxx"}.NewBetterstackHandler()) - logger = logger.With("release", "v1.0.0") - - logger. - With( - slog.Group("user", - slog.String("id", "user-123"), - slog.Time("created_at", time.Now()), - ), - ). - With("error", fmt.Errorf("an error")). - Error("a message", slog.Int("count", 1)) +The record above arrives in Better Stack as: + +```json +{ + "dt": "2026-10-06T09:50:54.721381Z", + "level": "ERROR", + "message": "a message", + "logger.name": "BetterStackHQ/slog-betterstack", + "logger.version": "1.4.4", + "extra": { + "release": "v1.0.0", + "user": {"id": "user-123", "created_at": "2026-10-06T11:50:54.721344+02:00"}, + "error": {"kind": "*errors.errorString", "error": "an error", "stack": null}, + "count": 1 + } } ``` ### Tracing -Import the samber/slog-otel library. +Attach trace and span ids from the context with [samber/slog-otel](https://github.com/samber/slog-otel): ```go import ( - slogbetterstack "github.com/samber/slog-betterstack" + slogbetterstack "github.com/BetterStackHQ/slog-betterstack" slogotel "github.com/samber/slog-otel" - "go.opentelemetry.io/otel/sdk/trace" ) -func main() { - tp := trace.NewTracerProvider( - trace.WithSampler(trace.AlwaysSample()), - ) - tracer := tp.Tracer("hello/world") - - ctx, span := tracer.Start(context.Background(), "foo") - defer span.End() - - span.AddEvent("bar") - - logger := slog.New( - slogbetterstack.Option{ - // ... - AttrFromContext: []func(ctx context.Context) []slog.Attr{ - slogotel.ExtractOtelAttrFromContext([]string{"tracing"}, "trace_id", "span_id"), - }, - }.NewBetterstackHandler(), - ) +logger := slog.New( + slogbetterstack.Option{ + Token: "$SOURCE_TOKEN", + AttrFromContext: []func(ctx context.Context) []slog.Attr{ + slogotel.ExtractOtelAttrFromContext([]string{"tracing"}, "trace_id", "span_id"), + }, + }.NewBetterstackHandler(), +) - logger.ErrorContext(ctx, "a message") -} +logger.ErrorContext(ctx, "a message") ``` -## 🤝 Contributing +To log to Better Stack and somewhere else at the same time, combine handlers with +[samber/slog-multi](https://github.com/samber/slog-multi) or, from Go 1.26, `slog.NewMultiHandler`. -- Ping me on Twitter [@samuelberthe](https://twitter.com/samuelberthe) (DMs, mentions, whatever :)) -- Fork the [project](https://github.com/samber/slog-betterstack) -- Fix [open issues](https://github.com/samber/slog-betterstack/issues) or request new features +## Upgrading from samber/slog-betterstack -Don't hesitate ;) +This repository continues [samber/slog-betterstack](https://github.com/samber/slog-betterstack), +which Better Stack maintains as its official Go client from version 1.5.0 on. The API and the +shape of the logs are unchanged, so upgrading means replacing the import path: -```bash -# Install some dev dependencies -make tools - -# Run tests -make test -# or -make watch-test +```sh +go get github.com/BetterStackHQ/slog-betterstack@latest +go mod edit -droprequire github.com/samber/slog-betterstack ``` -## 👤 Contributors - -![Contributors](https://contrib.rocks/image?repo=samber/slog-betterstack) +```go +import slogbetterstack "github.com/BetterStackHQ/slog-betterstack" +``` -## 💫 Show your support +## Need help? -Give a ⭐️ if this project helped you! +Please let us know at [hello@betterstack.com](mailto:hello@betterstack.com). We're happy to help! -[![GitHub Sponsors](https://img.shields.io/github/sponsors/samber?style=for-the-badge)](https://github.com/sponsors/samber) +## Credits -## 📝 License +slog-betterstack was created and maintained by [Samuel Berthe](https://github.com/samber) as part +of his [slog ecosystem](https://github.com/samber?tab=repositories&q=slog) and released under the +MIT license. Thank you, Samuel! ❤️ -Copyright © 2023 [Samuel Berthe](https://github.com/samber). +--- -This project is [MIT](./LICENSE) licensed. +[MIT license](LICENSE), [example project](example-project/) From d4daff83e8a4c9b5da8d892583c45fdd87599800 Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 15:05:33 +0200 Subject: [PATCH 5/8] T-20356 Standard README: docs link and credits only Usage documentation lives at betterstack.com/docs/logs/go like for the other clients, so the README keeps the house layout plus the credits to Samuel Berthe. Co-Authored-By: Claude Fable 5.1 --- README.md | 155 +----------------------------------------------------- 1 file changed, 1 insertion(+), 154 deletions(-) diff --git a/README.md b/README.md index cd7bab7..90565a0 100644 --- a/README.md +++ b/README.md @@ -8,168 +8,15 @@ Experience SQL-compatible structured log management based on ClickHouse. [Learn more ⇗](https://betterstack.com/logs) -A [`log/slog`](https://pkg.go.dev/log/slog) handler that sends the logs of your Go application to Better Stack. - ## Documentation [Getting started ⇗](https://betterstack.com/docs/logs/go/) -## Install - -```sh -go get github.com/BetterStackHQ/slog-betterstack -``` - -Go 1.21 or newer. - -## Usage - -```go -package main - -import ( - "log/slog" - - slogbetterstack "github.com/BetterStackHQ/slog-betterstack" -) - -func main() { - logger := slog.New( - slogbetterstack.Option{ - Token: "$SOURCE_TOKEN", - Endpoint: "https://$INGESTING_HOST/", - }.NewBetterstackHandler(), - ) - - logger.Info("Hello from Better Stack!", "service", "UserService") -} -``` - -Find the source token and the ingesting host of your source in Better Stack → Sources. - -Logs are sent asynchronously. For short-lived programs, such as CLI tools or one-off scripts, give -the handler a moment to finish sending before the process exits, for example with -`time.Sleep(5 * time.Second)` after the last log line. Long-running services are not affected. - -### Options - -```go -type Option struct { - // log level (default: debug) - Level slog.Leveler - - // token - Token string - // optional: endpoint - Endpoint string - // default: 10s - Timeout time.Duration - - // optional: customize record builder - Converter Converter - // optional: custom marshaler - Marshaler func(v any) ([]byte, error) - // optional: fetch attributes from context - AttrFromContext []func(ctx context.Context) []slog.Attr - - // optional: see slog.HandlerOptions - AddSource bool - ReplaceAttr func(groups []string, a slog.Attr) slog.Attr -} -``` - -Package-level settings: - -```go -slogbetterstack.SourceKey = "runtime" // where AddSource puts the call site -slogbetterstack.ContextKey = "extra" // where attributes are nested -slogbetterstack.ErrorKeys = []string{"error", "err"} // attributes expanded as errors -``` - -### Structured data - -```go -logger := slog.New(slogbetterstack.Option{Level: slog.LevelDebug, Token: "$SOURCE_TOKEN"}.NewBetterstackHandler()) -logger = logger.With("release", "v1.0.0") - -logger. - With( - slog.Group("user", - slog.String("id", "user-123"), - slog.Time("created_at", time.Now()), - ), - ). - With("error", fmt.Errorf("an error")). - Error("a message", slog.Int("count", 1)) -``` - -The record above arrives in Better Stack as: - -```json -{ - "dt": "2026-10-06T09:50:54.721381Z", - "level": "ERROR", - "message": "a message", - "logger.name": "BetterStackHQ/slog-betterstack", - "logger.version": "1.4.4", - "extra": { - "release": "v1.0.0", - "user": {"id": "user-123", "created_at": "2026-10-06T11:50:54.721344+02:00"}, - "error": {"kind": "*errors.errorString", "error": "an error", "stack": null}, - "count": 1 - } -} -``` - -### Tracing - -Attach trace and span ids from the context with [samber/slog-otel](https://github.com/samber/slog-otel): - -```go -import ( - slogbetterstack "github.com/BetterStackHQ/slog-betterstack" - slogotel "github.com/samber/slog-otel" -) - -logger := slog.New( - slogbetterstack.Option{ - Token: "$SOURCE_TOKEN", - AttrFromContext: []func(ctx context.Context) []slog.Attr{ - slogotel.ExtractOtelAttrFromContext([]string{"tracing"}, "trace_id", "span_id"), - }, - }.NewBetterstackHandler(), -) - -logger.ErrorContext(ctx, "a message") -``` - -To log to Better Stack and somewhere else at the same time, combine handlers with -[samber/slog-multi](https://github.com/samber/slog-multi) or, from Go 1.26, `slog.NewMultiHandler`. - -## Upgrading from samber/slog-betterstack - -This repository continues [samber/slog-betterstack](https://github.com/samber/slog-betterstack), -which Better Stack maintains as its official Go client from version 1.5.0 on. The API and the -shape of the logs are unchanged, so upgrading means replacing the import path: - -```sh -go get github.com/BetterStackHQ/slog-betterstack@latest -go mod edit -droprequire github.com/samber/slog-betterstack -``` - -```go -import slogbetterstack "github.com/BetterStackHQ/slog-betterstack" -``` - ## Need help? - Please let us know at [hello@betterstack.com](mailto:hello@betterstack.com). We're happy to help! ## Credits - -slog-betterstack was created and maintained by [Samuel Berthe](https://github.com/samber) as part -of his [slog ecosystem](https://github.com/samber?tab=repositories&q=slog) and released under the -MIT license. Thank you, Samuel! ❤️ +`slog-betterstack` was created and maintained by [Samuel Berthe](https://github.com/samber) and released under the MIT license. Thank you, Samuel! ❤️ --- From 6c00d235b96e57731775a37c7f8ce2fa0590939e Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 15:12:11 +0200 Subject: [PATCH 6/8] T-20356 Release workflow: major releases move the module path to /vN Go's semantic import versioning puts the major version into the module path from v2 on, so a major release rewrites go.mod, the imports of this repository and the README's pkg.go.dev links to "/vN" in the same version commit, and the publish job fetches that path through the proxy. Rehearsed on a clone: major gives 2.0.0 with a building /v2 module, minor and patch leave the path alone, retry reads the path from the tag. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 59 +++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01033fc..90e2cbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,15 +9,20 @@ # because main is unprotected or because the app is among the actors allowed to bypass the pull # request requirement, since the version commit and the tag are pushed by the workflow. # -# Release: Actions → Release → Run workflow from main, pick patch or minor. The workflow runs the -# tests, bumps the version in version.go, builds, commits "vX.Y.Z", tags it, pushes both, creates a -# GitHub release with auto-generated notes for the tag and fetches the version through the Go -# module proxy; edit the notes afterwards if needed. +# Release: Actions → Release → Run workflow from main, pick patch, minor or major. The workflow +# runs the tests, bumps the version in version.go, builds, commits "vX.Y.Z", tags it, pushes both, +# creates a GitHub release with auto-generated notes for the tag and fetches the version through +# the Go module proxy; edit the notes afterwards if needed. +# +# Major: Go's semantic import versioning puts the major version into the module path from v2 on, +# so a major release also moves the module to "/vN" in go.mod, in every import of this +# repository and in the README's pkg.go.dev links, all in the same version commit. Users upgrade +# by changing their import path; the previous major keeps working at the old path. # # Retry: if a release failed after the version commit was pushed, run "retry" from main. It bumps # nothing, checks out the tag of the version in version.go and finishes the release from there: the -# GitHub release is created if it is still missing and the proxy is asked again. Running patch or -# minor again would release the next version instead. +# GitHub release is created if it is still missing and the proxy is asked again. Running patch, +# minor or major again would release the next version instead. # # Dry run: bumps in place without committing and builds; nothing is pushed or published. Every push # that touches this file is a dry run, so a change to the workflow proves itself on its pull request @@ -28,9 +33,9 @@ on: workflow_dispatch: inputs: release: - description: "patch or minor: bump, tag and publish. retry: finish a release that failed halfway." + description: "patch, minor or major: bump, tag and publish. retry: finish a release that failed halfway." type: choice - options: [patch, minor, retry] + options: [patch, minor, major, retry] required: true dry_run: description: "Dry run: bump and build, publish nothing" @@ -50,7 +55,6 @@ concurrency: env: DRY_RUN: ${{ github.event_name == 'push' || inputs.dry_run }} RELEASE: ${{ inputs.release || 'patch' }} - MODULE: github.com/BetterStackHQ/slog-betterstack jobs: verify: @@ -77,6 +81,7 @@ jobs: contents: write # pushes the version commit and the tag outputs: version: ${{ steps.version.outputs.version }} + module: ${{ steps.version.outputs.module }} steps: - name: Releases run from main only @@ -121,20 +126,53 @@ jobs: if tagged and not dry_run: sys.exit(f"HEAD is already released as v{current}, there is nothing new to release") major, minor, patch = map(int, current.split(".")) - version = f"{major}.{minor + 1}.0" if release == "minor" else f"{major}.{minor}.{patch + 1}" + if release == "major": + version = f"{major + 1}.0.0" + elif release == "minor": + version = f"{major}.{minor + 1}.0" + else: + version = f"{major}.{minor}.{patch + 1}" open(path, "w").write(source.replace(f'"{current}"', f'"{version}"', 1)) + # Semantic import versioning: from v2 on, the module path ends in /vN. The path lives in + # go.mod, in the imports of this repository (the example project) and in the README's + # pkg.go.dev links; GitHub URLs of the repository are not import paths and stay as they are. + gomod = open("go.mod").read() + module = re.search(r"^module (\S+)$", gomod, re.M).group(1) + base = re.sub(r"/v\d+$", "", module) + major = int(version.split(".")[0]) + wanted = base if major < 2 else f"{base}/v{major}" + if release != "retry" and wanted != module: + suffixed = re.escape(base) + r"(?:/v\d+)?" + open("go.mod", "w").write(gomod.replace(f"module {module}", f"module {wanted}", 1)) + for root, dirs, files in os.walk("."): + dirs[:] = [d for d in dirs if d != ".git"] + for name in files: + if not name.endswith(".go"): + continue + file = os.path.join(root, name) + text = open(file).read() + rewritten = re.sub(r'"' + suffixed + r'"', f'"{wanted}"', text) + if rewritten != text: + open(file, "w").write(rewritten) + readme = open("README.md").read() + open("README.md", "w").write(re.sub(r"pkg\.go\.dev/(badge/)?" + suffixed, lambda m: f"pkg.go.dev/{m.group(1) or ''}{wanted}", readme)) + print(f"module path {module} -> {wanted}") + print(f"{current} -> {version}") with open(os.environ["GITHUB_OUTPUT"], "a") as output: output.write(f"version={version}\n") + output.write(f"module={wanted}\n") EOF - name: Build env: VERSION: ${{ steps.version.outputs.version }} + MODULE: ${{ steps.version.outputs.module }} run: | git diff --stat grep -q "const version = \"$VERSION\"" version.go || { echo "::error::version.go does not carry $VERSION."; exit 1; } + grep -q "^module $MODULE$" go.mod || { echo "::error::go.mod does not declare module $MODULE."; exit 1; } go build ./... go vet ./... make fmt @@ -160,6 +198,7 @@ jobs: contents: write # creates the GitHub release env: VERSION: ${{ needs.build.outputs.version }} + MODULE: ${{ needs.build.outputs.module }} steps: - name: Dry run From f82ee413a6861daf1538982fb48f3123e772330e Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 15:12:11 +0200 Subject: [PATCH 7/8] T-20356 Cover WithGroup("") and the send error paths Statement coverage goes from 92% to 100%. Co-Authored-By: Claude Fable 5.1 --- handler_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/handler_test.go b/handler_test.go index 8746399..aeba805 100644 --- a/handler_test.go +++ b/handler_test.go @@ -345,3 +345,35 @@ func TestConverterAndMarshaler(t *testing.T) { t.Error("Marshaler was not called") } } + +func TestWithGroupEmptyNameIsANoOp(t *testing.T) { + handler := Option{Token: "x"}.NewBetterstackHandler() + if handler.WithGroup("") != handler { + t.Error(`WithGroup("") returned a new handler, want the same one`) + } +} + +func TestSendErrors(t *testing.T) { + payload := []map[string]any{{"message": "m"}} + + t.Run("marshaling", func(t *testing.T) { + failing := func(any) ([]byte, error) { return nil, errors.New("cannot marshal") } + if err := send("http://127.0.0.1:0/", "x", time.Second, failing, payload); err == nil || err.Error() != "cannot marshal" { + t.Errorf("err = %v, want the Marshaler's error", err) + } + }) + + t.Run("invalid endpoint", func(t *testing.T) { + if err := send("://not-a-url", "x", time.Second, json.Marshal, payload); err == nil { + t.Error("err = nil, want a request error for an invalid endpoint") + } + }) + + t.Run("unreachable endpoint", func(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() // nothing listens on this URL any more + if err := send(server.URL, "x", time.Second, json.Marshal, payload); err == nil { + t.Error("err = nil, want a connection error for a closed endpoint") + } + }) +} From e1bbaa26e93b3dc5746fc29a366e53b254cc28bd Mon Sep 17 00:00:00 2001 From: Petr Heinz Date: Tue, 6 Oct 2026 15:12:11 +0200 Subject: [PATCH 8/8] T-20356 README badges: license, latest version, CI status The version badge reads the latest GitHub release and links to pkg.go.dev, the Go equivalent of the package registry pages the other clients link to. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 90565a0..e47c2b3 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Better Stack dashboard](https://github.com/logtail/logtail-python/assets/10132717/e2a1196b-7924-4abc-9b85-055e17b5d499)](https://betterstack.com/logs) [![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) -[![Go Reference](https://pkg.go.dev/badge/github.com/BetterStackHQ/slog-betterstack.svg)](https://pkg.go.dev/github.com/BetterStackHQ/slog-betterstack) +[![Latest version](https://img.shields.io/github/v/release/BetterStackHQ/slog-betterstack?label=version&sort=semver)](https://pkg.go.dev/github.com/BetterStackHQ/slog-betterstack) [![build](https://github.com/BetterStackHQ/slog-betterstack/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/BetterStackHQ/slog-betterstack/actions/workflows/main.yml) Experience SQL-compatible structured log management based on ClickHouse. [Learn more ⇗](https://betterstack.com/logs)