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/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..90e2cbe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,59 +1,237 @@ +# 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, 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, +# 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 +# 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, minor or major: bump, tag and publish. retry: finish a release that failed halfway." + type: choice + options: [patch, minor, major, 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' }} 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 }} + module: ${{ steps.version.outputs.module }} + + 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(".")) + 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 + + - 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 }} + MODULE: ${{ needs.build.outputs.module }} + 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/.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/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/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 diff --git a/README.md b/README.md index 362a8c3..e47c2b3 100644 --- a/README.md +++ b/README.md @@ -1,227 +1,23 @@ +# [Better Stack](https://betterstack.com/logs) Go client -# slog: Betterstack handler +[![Better Stack dashboard](https://github.com/logtail/logtail-python/assets/10132717/e2a1196b-7924-4abc-9b85-055e17b5d499)](https://betterstack.com/logs) -[![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) +[![MIT License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![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) -A [Betterstack](https://betterstack.com) Handler for [slog](https://pkg.go.dev/log/slog) Go library. +Experience SQL-compatible structured log management based on ClickHouse. [Learn more ⇗](https://betterstack.com/logs) -
-
- Sponsored by: -
- -
- Cast AI -
-
- Cut Kubernetes & AI costs, boost application stability -
-
-
- -
- Dash0 -
-
- 100% OpenTelemetry-native observability platform
Simple to use, built on open standards, and designed for full cost control -
-
-
-
+## Documentation -**See also:** +[Getting started ⇗](https://betterstack.com/docs/logs/go/) -- [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 +## Need help? +Please let us know at [hello@betterstack.com](mailto:hello@betterstack.com). We're happy to help! -**HTTP middlewares:** +## Credits +`slog-betterstack` was created and maintained by [Samuel Berthe](https://github.com/samber) and released under the MIT license. Thank you, Samuel! ❤️ -- [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 - -```sh -go get github.com/samber/slog-betterstack -``` - -**Compatibility**: go >= 1.21 - -No breaking changes will be made to exported APIs before v2.0.0. - -## 💡 Usage - -GoDoc: [https://pkg.go.dev/github.com/samber/slog-betterstack](https://pkg.go.dev/github.com/samber/slog-betterstack) - -### Handler 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 -} -``` - -Other global parameters: - -```go -slogbetterstack.SourceKey = "runtime" -slogbetterstack.ContextKey = "context" -slogbetterstack.ErrorKeys = []string{"error", "err"} -``` - -### Example - -```go -import ( - "fmt" - "net/http" - "time" - - slogbetterstack "github.com/samber/slog-betterstack" - - "log/slog" -) - -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)) -} -``` - -### Tracing - -Import the samber/slog-otel library. - -```go -import ( - slogbetterstack "github.com/samber/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.ErrorContext(ctx, "a message") -} -``` - -## 🤝 Contributing - -- 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 - -Don't hesitate ;) - -```bash -# Install some dev dependencies -make tools - -# Run tests -make test -# or -make watch-test -``` - -## 👤 Contributors - -![Contributors](https://contrib.rocks/image?repo=samber/slog-betterstack) - -## 💫 Show your support - -Give a ⭐️ if this project helped you! - -[![GitHub Sponsors](https://img.shields.io/github/sponsors/samber?style=for-the-badge)](https://github.com/sponsors/samber) - -## 📝 License - -Copyright © 2023 [Samuel Berthe](https://github.com/samber). - -This project is [MIT](./LICENSE) licensed. +[MIT license](LICENSE), [example project](example-project/) 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/handler_test.go b/handler_test.go new file mode 100644 index 0000000..aeba805 --- /dev/null +++ b/handler_test.go @@ -0,0 +1,379 @@ +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") + } +} + +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") + } + }) +} 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"