From 96c35d466fb8c38a56f3a5e12525fabec4fc8caf Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 10:43:19 -0400 Subject: [PATCH 01/13] Modernize SDK to v1.1.0 (reliability, client API, transport, tooling) --- .github/workflows/tests.yml | 66 ++++- .gitignore | 14 + .golangci.yml | 24 ++ Makefile | 42 +++ README.md | 201 +++++++++---- attributes.go | 239 ++++++++++++++++ attributes_test.go | 112 ++++++++ bcd.go | 73 +++-- bcd_sys_linux.go | 2 +- bcd_sys_unsupported.go | 2 +- breadcrumbs.go | 99 +++++++ bthttp/bthttp.go | 99 +++++++ bthttp/bthttp_test.go | 150 ++++++++++ client.go | 477 +++++++++++++++++++++++++++++++ client_test.go | 458 +++++++++++++++++++++++++++++ config.go | 233 +++++++++++++++ config_test.go | 140 +++++++++ examples/bcd/main.go | 134 +++++++++ examples/main.go | 283 ------------------ examples/report/main.go | 64 +++++ go.mod | 14 +- go.sum | 16 +- logger.go | 34 +++ main.go | 555 +++++++++++++----------------------- main_test.go | 320 ++++++++++++++++----- procmeminfo.go | 88 +++--- procmeminfo_test.go | 28 +- report.go | 104 +++++++ threads.go | 355 +++++++++++++++++------ threads_test.go | 512 +++++++++++++++++++++++---------- tracer.go | 102 +++++-- tracer_darwin_stub.go | 19 +- tracer_test.go | 85 ++++++ transport.go | 216 ++++++++++++++ transport_test.go | 69 +++++ version.go | 13 + 36 files changed, 4296 insertions(+), 1146 deletions(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 attributes.go create mode 100644 attributes_test.go create mode 100644 breadcrumbs.go create mode 100644 bthttp/bthttp.go create mode 100644 bthttp/bthttp_test.go create mode 100644 client.go create mode 100644 client_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 examples/bcd/main.go delete mode 100644 examples/main.go create mode 100644 examples/report/main.go create mode 100644 logger.go create mode 100644 report.go create mode 100644 tracer_test.go create mode 100644 transport.go create mode 100644 transport_test.go create mode 100644 version.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1569481..0f42435 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,12 +6,38 @@ on: - master pull_request: +permissions: + contents: read + jobs: - run-tests: - name: lint-and-test + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + - name: Check gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "gofmt required for:"; echo "$unformatted"; exit 1 + fi + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: latest + + test: + name: test (go ${{ matrix.go-version }}, ${{ matrix.os }}) strategy: + fail-fast: false matrix: - go-version: [1.18, 1.22] + # Support policy: the two most recent Go releases (go.mod + # declares the minimum). + go-version: ['1.25.x', '1.26.x'] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: @@ -20,10 +46,34 @@ jobs: uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + - name: Vet + run: go vet ./... + - name: Run unit tests (race detector) + run: go test -race -count=1 ./... + + cross-compile: + name: build (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - linux/amd64 + - linux/arm + - linux/arm64 + - windows/amd64 + - darwin/arm64 + - freebsd/amd64 + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - version: v1.60 - - name: Run Unit Tests + go-version: stable + - name: Build run: | - go test + export GOOS="${TARGET%/*}" GOARCH="${TARGET#*/}" + go build ./... + go vet ./... + env: + TARGET: ${{ matrix.target }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58d9fa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# OS artifacts +.DS_Store + +# Test and coverage artifacts +*.test +coverage.out + +# Example tracer output +tracedir/ +tracelog + +# Editor directories +.idea/ +.vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b99ecda --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,24 @@ +# golangci-lint v2 configuration. +version: "2" + +linters: + # standard: errcheck, govet, ineffassign, staticcheck, unused. + default: standard + settings: + errcheck: + # Response/file Close in defers is intentionally best-effort. + exclude-functions: + - (net/http.ResponseWriter).Write + - (io.Closer).Close + - (*os.File).Close + - (net.Listener).Close + exclusions: + rules: + # Test helpers may ignore errors for brevity. + - path: _test\.go + linters: + - errcheck + +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ef8f9a --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: help build test race vet fmt fmt-check lint tidy cover cross clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "%-12s %s\n", $$1, $$2}' + +build: ## Build all packages + go build ./... + +test: ## Run unit tests + go test -count=1 ./... + +race: ## Run unit tests with the race detector + go test -race -count=1 ./... + +vet: ## Run go vet + go vet ./... + +fmt: ## Format all Go files + gofmt -w . + +fmt-check: ## Fail if any file needs formatting + @unformatted="$$(gofmt -l .)"; if [ -n "$$unformatted" ]; then \ + echo "gofmt required for:"; echo "$$unformatted"; exit 1; fi + +lint: ## Run golangci-lint (requires golangci-lint v2) + golangci-lint run ./... + +tidy: ## Verify go.mod/go.sum are tidy + go mod tidy + +cover: ## Run tests with coverage report + go test -race -count=1 -coverprofile=coverage.out ./... + go tool cover -func=coverage.out | tail -1 + +cross: ## Cross-compile for all supported platforms + @for target in linux/amd64 linux/arm linux/arm64 windows/amd64 darwin/arm64 freebsd/amd64; do \ + echo "building $$target"; \ + GOOS=$${target%/*} GOARCH=$${target#*/} go build ./... || exit 1; \ + done + +clean: ## Remove build artifacts + rm -f coverage.out diff --git a/README.md b/README.md index 5ba70d9..dcaa9c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # backtrace-go -[Backtrace](http://backtrace.io/) error reporting tool for Go. +[Backtrace](https://backtrace.io/) error reporting SDK for Go. + +Reports errors, messages, and panics — with goroutine stacks, source context, rich attributes, breadcrumbs, and file attachments to the Backtrace (Sauce Labs) platform. +The package also ships an integration with out-of-process tracers ([bcd](#bcd-out-of-process-tracing)). ## Installation @@ -8,94 +11,166 @@ go get github.com/backtrace-labs/backtrace-go ``` -## Usage +Requires Go 1.25+. The only dependency is `golang.org/x/sys`. + +## Quick start + +```go +package main + +import ( + "errors" -In Go there are three ways errors can happen: + bt "github.com/backtrace-labs/backtrace-go" +) - * An operation produces an `error` return value. - * A goroutine calls `panic`. - * A native library crashes or the Go runtime itself crashes. +func main() { + client, err := bt.NewClient(bt.Config{ + Endpoint: "https://submit.backtrace.io/{universe}/{token}/json", + }) + if err != nil { + // Endpoint missing or malformed. + panic(err) + } + defer client.Close() + + client.Report(errors.New("something went wrong"), map[string]interface{}{ + "request.id": "abc-123", + }) +} +``` -backtrace-go handles `error` and `panic` situations. However, there are some -caveats with handling panics: +Two endpoint forms are supported: - * In order to capture error reports in a panic scenario, every goroutine must - make an API call to set up panic handling. - * It's possible to forget to do this setup, and you might not know when a - callback is executed as a goroutine. - * If a Go application makes any calls into native libraries, a crash in a - native library will crash without causing a panic. +- `https://submit.backtrace.io/{universe}/{token}/json` | `Endpoint` only | +- `https://{universe}.sp.backtrace.io` | `Endpoint` + `Token` | -Fortunately, there is a robust solution which can capture an error report -in all of these circumstances. This is a Backtrace product called -[Coresnap](https://documentation.backtrace.io/coresnapintro/) which supports -deep introspection into the state of Go applications. +`BACKTRACE_ENDPOINT` and `BACKTRACE_TOKEN` environment variables are used as fallbacks when the corresponding fields are empty. -The recommended way to capture error reports in a Go application is to use -coresnap to handle panics and crashes, and to use backtrace-go to report -non-fatal error conditions. +## Reporting ```go -import ( - "http" +client.Report(err, nil) // error (type + unwrap chain captured) +client.ReportMessage("cache warmup skipped", nil) // plain message +client.ReportPanicValue(recovered, nil) // recovered panic value - "github.com/backtrace-labs/backtrace-go" -) +// Panic capture with defer: +defer bt.ReportPanic(nil) // reports, flushes, re-panics +defer bt.ReportAndRecoverPanic(nil) // reports and swallows the panic +``` -func init() { - bt.Options.Endpoint = "https://console.backtrace.io" - bt.Options.Token = "51cc8e69c5b62fa8c72dc963e730f1e8eacbd243aeafc35d08d05ded9a024121" -} +Reporting never blocks the caller on network I/O: reports are queued to a background worker, and when the queue is full new reports are dropped and counted (`client.DroppedReports()`) instead of stalling the application. -func foo() { - response, err := http.Get("https://doesnotexistexample.com") - if err != nil { - bt.Report(err, nil) - } -} +Delivery lifecycle: + +```go +client.Flush(5 * time.Second) // wait for queued reports; client stays usable +client.Close() // drain, stop the worker, release the client +``` + +## Configuration + +```go +client, err := bt.NewClient(bt.Config{ + Endpoint: "https://submit.backtrace.io/{universe}/{token}/json", + CaptureAllGoroutines: true, // include every goroutine's stack + SourceCode: bt.SourceCodeContext, // context lines (default), File, or None + ContextLineCount: 8, // lines above/below each frame + Attributes: map[string]interface{}{ // stamped on every report + "application.environment": "production", + }, + AttachmentPaths: []string{"/var/log/app.log"}, // uploaded with every report + SendEnvVars: true, // env vars as annotation, secrets redacted + SampleRate: 1.0, // fraction of reports sent (0 == 1.0) + BeforeSend: func(r *bt.ReportData) *bt.ReportData { + delete(r.Attributes, "secret") // scrub, enrich, or return nil to drop + return r + }, + Debug: false, // diagnostic logging; the SDK never panics either way +}) +``` + +All zero values are sensible defaults: 30s HTTP timeout, queue of 128, 8 context lines, 64 breadcrumbs, error chains capped at 100. + +### Attributes, breadcrumbs + +```go +client.SetAttribute("user.id", "u-42") // safe from any goroutine +client.AddBreadcrumb(bt.Breadcrumb{ + Message: "checkout started", + Level: bt.BreadcrumbInfo, +}) ``` -## Documentation +Every report automatically includes: hostname, process ID and age, Go version, goroutine count, heap statistics, GC count, CPU architecture and model, OS version, machine GUID, `application.version` / `vcs.revision` (from Go build info), the Go module dependency list, and — on Linux —`/proc` memory and scheduler attributes. + +### net/http middleware -### bt.Report(msg interface{}, attributes map[string]string) +```go +import "github.com/backtrace-labs/backtrace-go/bthttp" + +handler := bthttp.New(bthttp.Options{ + Client: client, // omit to use the global reporter + Repanic: false, // re-raise after reporting + WaitForDelivery: true, // block the failing request until delivered +}) +http.ListenAndServe(":8080", handler.Handle(mux)) +``` -msg can be an `error` or something that can be converted to a `string`. -`attributes` are added to the report. +Panics in handlers are reported with `request.url`, `request.method`, +`request.remote_addr`, and `request.user_agent` attributes. -### bt.ReportPanic(attributes map[string]string) +## Legacy global API -Sends an error report in the event of a panic. +The historical package-level API keeps working unchanged: ```go -defer bt.ReportPanic(nil) -somethingThatMightPanic() +import bt "github.com/backtrace-labs/backtrace-go" + +func init() { + bt.Options.Endpoint = "https://submit.backtrace.io/{universe}/{token}/json" +} + +func foo() { + if err := doWork(); err != nil { + bt.Report(err, nil) + } +} ``` -### bt.ReportAndRecoverPanic(attributes map[string]string) +Notes: + +- Configure `bt.Options` before the first report. For attribute changes at runtime use `bt.SetAttribute` / `bt.SetAttributes`, which are safe for concurrent use. +- `bt.FinishSendingReports()` now waits for queued reports **without** stopping the reporter (historically it killed the sender permanently): prefer `bt.Flush(timeout)`. +- Source capture now defaults to context lines around each frame instead of whole files: opt back in with `Options.SourceCode = bt.SourceCodeFile`. +- The SDK never panics. `DebugBacktrace` only controls diagnostic logging. + +## Thread-safety contract -This is the same as `bt.ReportPanic` but it recovers from the -panic and the goroutine lives on. +`Client` methods, the package-level reporting functions, `SetAttribute`, +`AddBreadcrumb`, `Flush`, and `Close` are safe for concurrent use. The +`Options` struct and `Config` maps are read when reports are captured; +mutate them only before reporting starts (or via `SetAttribute`). -### bt.FinishSendingReports() +# bcd (out-of-process tracing) -backtrace-go sends reports in a goroutine to avoid blocking. -When your application shuts down it will abort any ongoing sending of -reports. Call this function to block until all queued reports are done -sending. +The `bt` package also provides integration with out-of-process tracers. +Using the provided `Tracer` interface, applications may invoke tracer execution on demand: panic and signal handling integrations are provided. +A default `Tracer` implementation for the Backtrace platform (`BTTracer`, Linux/FreeBSD) is included. -# bcd +See the [godoc](https://pkg.go.dev/github.com/backtrace-labs/backtrace-go) +and [examples/bcd/main.go](examples/bcd/main.go). -Package provides integration with out of process tracers. Using the provided -Tracer interface, applications may invoke tracer execution on demand. Panic and -signal handling integrations are provided. +## Examples -The Tracer interface is generic and will support any out of process tracer -implementing it. A default Tracer implementation, which uses the Backtrace I/O -platform, is provided. +- [examples/report](examples/report/main.go) — error reporting, breadcrumbs, BeforeSend, middleware. +- [examples/bcd](examples/bcd/main.go) — tracer integration: signals, panic recovery, snapshot upload. -## Usage +## Development -See the [godoc page](https://godoc.org/github.com/backtrace-labs/backtrace-go) for -current documentation; -see [this](https://github.com/backtrace-labs/backtrace-go/blob/master/examples/main.go) -for an example application. +``` +make help # list targets +make race # go test -race ./... +make lint # golangci-lint v2 +make cross # cross-compile all supported platforms +``` diff --git a/attributes.go b/attributes.go new file mode 100644 index 0000000..8f0d4e9 --- /dev/null +++ b/attributes.go @@ -0,0 +1,239 @@ +package bt + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "sync" + "time" +) + +// execCommandTimeout bounds every machine-metadata subprocess. +const execCommandTimeout = 2 * time.Second + +var ( + windowsGUIDCommand = []string{"reg", "query", `HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography`, "/v", "MachineGuid"} + linuxGUIDCommand = []string{"sh", "-c", "( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :"} + freebsdGUIDCommand = []string{"sh", "-c", "kenv -q smbios.system.uuid || sysctl -n kern.hostuuid"} + darwinGUIDCommand = []string{"sh", "-c", "ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID | awk -F'= \"' '{print $2}' | tr -d '\"' | tr -d '\n'"} + + windowsCPUCommand = []string{"wmic", "CPU", "get", "NAME"} + linuxCPUCommand = []string{"sh", "-c", "lscpu | grep \"Model name\" | awk -F':' '{print $2}' | sed 's/^[[:space:]]*//'"} + darwinCPUCommand = []string{"sh", "-c", "sysctl -n machdep.cpu.brand_string | tr -d '\n'"} + freebsdCPUCommand = []string{"sh", "-c", "sysctl -n hw.model"} + + linuxOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} + darwinOSVersionCommand = []string{"sh", "-c", "sw_vers | grep ProductVersion | awk -F':' '{print $2}' | tr -d '\t' | tr -d '\n'"} + freebsdOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} +) + +// processSessionID identifies this process instance across its reports. +var processSessionID = uuid4() + +var processStart = time.Now() + +var ( + staticOnce sync.Once + staticCache map[string]interface{} +) + +// staticAttributes are cheap, in-process values stamped on every report. +// Computed once: none of them change during the process lifetime. +func staticAttributes() map[string]interface{} { + staticOnce.Do(func() { + staticCache = computeStaticAttributes() + }) + return staticCache +} + +func computeStaticAttributes() map[string]interface{} { + hostname, _ := os.Hostname() + return map[string]interface{}{ + "backtrace.version": Version, + "backtrace.agent": "backtrace-go", + "hostname": hostname, + "uname.sysname": runtime.GOOS, + "cpu.arch": runtime.GOARCH, + "cpu.count": runtime.NumCPU(), + "process.id": os.Getpid(), + "application": filepath.Base(os.Args[0]), + "application.session": processSessionID, + "go.version": runtime.Version(), + } +} + +// runtimeAttributes captures per-report runtime state. Kept cheap: no forced +// GC, no stop-the-world beyond ReadMemStats' brief pause. +func runtimeAttributes(attrs map[string]interface{}) { + attrs["runtime.goroutines"] = runtime.NumGoroutine() + attrs["runtime.gomaxprocs"] = runtime.GOMAXPROCS(0) + attrs["process.age"] = int64(time.Since(processStart).Seconds()) + + var m runtime.MemStats + runtime.ReadMemStats(&m) + attrs["memory.heap.alloc"] = m.HeapAlloc + attrs["memory.heap.sys"] = m.HeapSys + attrs["memory.heap.objects"] = m.HeapObjects + attrs["gc.count"] = m.NumGC +} + +var ( + machineOnce sync.Once + machineAttrs map[string]interface{} +) + +// machineAttributes gathers machine metadata (GUID, CPU model, OS version) +// by shelling out to platform tools. It runs at most once per process, on +// first use — never at import time — and each command is bounded by +// execCommandTimeout. Failures degrade to missing attributes. +func machineAttributes(d diag) map[string]interface{} { + machineOnce.Do(func() { + attrs := map[string]interface{}{} + + var guidCommand, cpuCommand, osCommand []string + switch runtime.GOOS { + case "windows": + guidCommand = windowsGUIDCommand + cpuCommand = windowsCPUCommand + case "linux": + guidCommand = linuxGUIDCommand + cpuCommand = linuxCPUCommand + osCommand = linuxOSVersionCommand + case "darwin": + guidCommand = darwinGUIDCommand + cpuCommand = darwinCPUCommand + osCommand = darwinOSVersionCommand + case "freebsd": + guidCommand = freebsdGUIDCommand + cpuCommand = freebsdCPUCommand + osCommand = freebsdOSVersionCommand + } + + if output := execCommand(guidCommand, d); output != "" { + if runtime.GOOS == "windows" { + // reg query output: + // HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography + // MachineGuid REG_SZ xxxxxxxx-xxxx-... + if fields := strings.Fields(output); len(fields) > 0 { + output = strings.Trim(fields[len(fields)-1], "{}") + } + } + attrs["guid"] = strings.TrimSpace(output) + } + + if output := execCommand(cpuCommand, d); output != "" { + if runtime.GOOS == "windows" { + // wmic output: header line "NAME" then the value. + if lines := strings.Split(output, "\n"); len(lines) > 1 { + output = lines[1] + } + } + attrs["cpu.brand"] = strings.TrimSpace(output) + } + + if output := execCommand(osCommand, d); output != "" { + attrs["uname.version"] = strings.TrimSpace(output) + } + + machineAttrs = attrs + }) + return machineAttrs +} + +// execCommand runs command[0] with the remaining arguments and returns its +// stdout, or "" on any failure. A nil/empty command returns "". +func execCommand(command []string, d diag) string { + if len(command) == 0 { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), execCommandTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, command[0], command[1:]...).Output() + if err != nil { + d.logf("machine attribute command %q failed: %v", command[0], err) + return "" + } + return string(out) +} + +var ( + buildInfoOnce sync.Once + buildInfoAttrs map[string]interface{} + buildInfoModules []string +) + +// buildInfoAttributes extracts release metadata embedded by the Go toolchain: +// main module version, VCS revision/time/dirty flag, and the dependency list +// (attached to reports as the "Dependencies" annotation). +func buildInfoAttributes() (map[string]interface{}, []string) { + buildInfoOnce.Do(func() { + attrs := map[string]interface{}{} + info, ok := debug.ReadBuildInfo() + if !ok { + buildInfoAttrs = attrs + return + } + if v := info.Main.Version; v != "" && v != "(devel)" { + attrs["application.version"] = v + } + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + attrs["vcs.revision"] = s.Value + case "vcs.time": + attrs["vcs.time"] = s.Value + case "vcs.modified": + attrs["vcs.modified"] = s.Value + } + } + modules := make([]string, 0, len(info.Deps)) + for _, dep := range info.Deps { + m := dep + if m.Replace != nil { + m = m.Replace + } + modules = append(modules, m.Path+"@"+m.Version) + } + buildInfoAttrs = attrs + buildInfoModules = modules + }) + return buildInfoAttrs, buildInfoModules +} + +// defaultEnvScrubPatterns match environment variable names whose values are +// redacted before submission. Case-insensitive substring match. +var defaultEnvScrubPatterns = []string{ + "TOKEN", "SECRET", "PASSWORD", "PASSWD", "APIKEY", "API_KEY", + "ACCESS_KEY", "SECRET_KEY", "PRIVATE_KEY", "CREDENTIAL", "AUTH", +} + +const redactedValue = "[REDACTED]" + +// getEnvVars returns the process environment with secret-looking values +// redacted. extraPatterns extends the built-in pattern list. +func getEnvVars(extraPatterns []string) map[string]string { + patterns := make([]string, 0, len(defaultEnvScrubPatterns)+len(extraPatterns)) + patterns = append(patterns, defaultEnvScrubPatterns...) + patterns = append(patterns, extraPatterns...) + + result := map[string]string{} + for _, line := range os.Environ() { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + upper := strings.ToUpper(key) + for _, p := range patterns { + if p != "" && strings.Contains(upper, strings.ToUpper(p)) { + value = redactedValue + break + } + } + result[key] = value + } + return result +} diff --git a/attributes_test.go b/attributes_test.go new file mode 100644 index 0000000..1272111 --- /dev/null +++ b/attributes_test.go @@ -0,0 +1,112 @@ +package bt + +import ( + "reflect" + "strings" + "testing" +) + +func TestGetEnvVarsSplitAndScrub(t *testing.T) { + t.Setenv("BT_ATTR_TEST_PLAIN", "a=b=c") + t.Setenv("BT_ATTR_TEST_MY_SECRET", "sensitive") + t.Setenv("BT_ATTR_TEST_API_KEY", "sensitive") + t.Setenv("BT_ATTR_TEST_CUSTOM", "sensitive") + + env := getEnvVars([]string{"BT_ATTR_TEST_CUSTOM"}) + + if env["BT_ATTR_TEST_PLAIN"] != "a=b=c" { + t.Errorf("value truncated at '=': %q", env["BT_ATTR_TEST_PLAIN"]) + } + if env["BT_ATTR_TEST_MY_SECRET"] != redactedValue { + t.Errorf("SECRET not redacted: %q", env["BT_ATTR_TEST_MY_SECRET"]) + } + if env["BT_ATTR_TEST_API_KEY"] != redactedValue { + t.Errorf("API_KEY not redacted: %q", env["BT_ATTR_TEST_API_KEY"]) + } + if env["BT_ATTR_TEST_CUSTOM"] != redactedValue { + t.Errorf("extra pattern not honored: %q", env["BT_ATTR_TEST_CUSTOM"]) + } +} + +func TestStaticAttributes(t *testing.T) { + attrs := staticAttributes() + for _, key := range []string{ + "backtrace.version", "backtrace.agent", "hostname", "uname.sysname", + "cpu.arch", "cpu.count", "process.id", "application", + "application.session", "go.version", + } { + if _, ok := attrs[key]; !ok { + t.Errorf("static attribute %q missing", key) + } + } + if attrs["backtrace.version"] != Version { + t.Errorf("backtrace.version = %v", attrs["backtrace.version"]) + } + if !strings.HasPrefix(attrs["go.version"].(string), "go") { + t.Errorf("go.version = %v", attrs["go.version"]) + } +} + +func TestRuntimeAttributes(t *testing.T) { + attrs := map[string]interface{}{} + runtimeAttributes(attrs) + for _, key := range []string{ + "runtime.goroutines", "runtime.gomaxprocs", "process.age", + "memory.heap.alloc", "memory.heap.sys", "gc.count", + } { + if _, ok := attrs[key]; !ok { + t.Errorf("runtime attribute %q missing", key) + } + } + if n := attrs["runtime.goroutines"].(int); n < 1 { + t.Errorf("runtime.goroutines = %d", n) + } +} + +func TestMachineAttributesCachedOnce(t *testing.T) { + first := machineAttributes(diag{}) + second := machineAttributes(diag{}) + // The maps must be the same instance (sync.Once semantics). + if reflect.ValueOf(first).Pointer() != reflect.ValueOf(second).Pointer() { + t.Error("machineAttributes returned different map instances; caching broken") + } +} + +func TestBuildInfoAttributesDoesNotPanic(t *testing.T) { + attrs, modules := buildInfoAttributes() + if attrs == nil { + t.Error("buildInfoAttrs is nil") + } + // In `go test` binaries the x/sys dependency must appear. + found := false + for _, m := range modules { + if strings.HasPrefix(m, "golang.org/x/sys@") { + found = true + } + } + if !found && len(modules) > 0 { + t.Errorf("x/sys missing from module list: %v", modules) + } +} + +func TestUnwrapErrorChainTypes(t *testing.T) { + err := &testWrapErr{msg: "outer", inner: &testWrapErr{msg: "inner"}} + chain := unwrapErrorChain(err, DefaultMaxErrorDepth) + if len(chain) != 2 { + t.Fatalf("chain length = %d", len(chain)) + } + if chain[0].Type != "*bt.testWrapErr" || chain[0].Message != "outer" { + t.Errorf("chain head = %+v", chain[0]) + } +} + +func TestBreadcrumbRingDisabled(t *testing.T) { + var r *breadcrumbRing // negative MaxBreadcrumbs => nil ring + r.add(Breadcrumb{Message: "ignored"}) + if got := r.snapshot(); got != nil { + t.Errorf("nil ring returned crumbs: %v", got) + } + if newBreadcrumbRing(-1) != nil { + t.Error("negative capacity should produce nil ring") + } +} diff --git a/bcd.go b/bcd.go index 52d3bad..f079eb0 100644 --- a/bcd.go +++ b/bcd.go @@ -8,6 +8,7 @@ package bt import ( + "bytes" "errors" "fmt" "os" @@ -289,16 +290,18 @@ func Register(t TracerSig) { t.Logf(LogDebug, "Registered tracer %s (signal set: %v)\n", t, ss) - state.m.RLock() - rs := state.c.ResendSignal - state.m.RUnlock() - go func(t TracerSig) { for s := range c { t.Logf(LogDebug, "Received %v; executing tracer\n", s) _ = Trace(t, &signalError{s}, nil) + // Read the configuration at signal time so that + // UpdateConfig calls made after Register are honored. + state.m.RLock() + rs := state.c.ResendSignal + state.m.RUnlock() + if !rs { continue } @@ -463,6 +466,7 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { }() done := make(chan tracerResult, 1) + started := make(chan struct{}) tracer := t.Finalize(options) if traceOptions.SpawnedGs != nil { @@ -477,8 +481,19 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { t.Logf(LogDebug, "Starting tracer %v\n", tracer) var res tracerResult + var stdOut bytes.Buffer + + tracer.Stdout = &stdOut + + if startErr := tracer.Start(); startErr != nil { + res.err = startErr + done <- res + return + } + close(started) - res.stdOut, res.err = tracer.Output() + res.err = tracer.Wait() + res.stdOut = stdOut.Bytes() done <- res t.Logf(LogDebug, "Tracer finished execution\n") @@ -490,23 +505,43 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { select { case <-timeout: - if err = tracer.Process.Kill(); err != nil { - t.Logf(LogError, - "Failed to kill tracer upon timeout: %v\n", - err) + // Only kill once the subprocess is known to have started; + // tracer.Process is nil before that (kill would panic). A + // result that is already available wins over the timeout. + select { + case res = <-done: + // The tracer failed to start or finished just as the + // timeout fired; fall through to result handling. + break + case <-started: + // One more non-blocking check: the tracer may have + // completed successfully between the two signals. + select { + case res = <-done: + break + default: + // A process that exited on its own just as + // the timeout fired is not a kill failure. + if err = tracer.Process.Kill(); err != nil && + !errors.Is(err, os.ErrProcessDone) { + t.Logf(LogError, + "Failed to kill tracer upon timeout: %v\n", + err) + + if kfPanic { + t.Logf(LogWarning, + "PanicOnKillFailure set; "+ + "panicking\n") + panic(err) + } + } + + err = errors.New("Tracer execution timed out") + t.Logf(LogError, "%v; process killed\n", err) - if kfPanic { - t.Logf(LogWarning, - "PanicOnKillFailure set; "+ - "panicking\n") - panic(err) + return } } - - err = errors.New("Tracer execution timed out") - t.Logf(LogError, "%v; process killed\n", err) - - return case res = <-done: break } diff --git a/bcd_sys_linux.go b/bcd_sys_linux.go index 6480fca..494cd46 100644 --- a/bcd_sys_linux.go +++ b/bcd_sys_linux.go @@ -1,4 +1,4 @@ -// +build !arm +//go:build linux package bt diff --git a/bcd_sys_unsupported.go b/bcd_sys_unsupported.go index 8f115ad..2a299ac 100644 --- a/bcd_sys_unsupported.go +++ b/bcd_sys_unsupported.go @@ -1,4 +1,4 @@ -// +build !linux arm +//go:build !linux package bt diff --git a/breadcrumbs.go b/breadcrumbs.go new file mode 100644 index 0000000..af16bee --- /dev/null +++ b/breadcrumbs.go @@ -0,0 +1,99 @@ +package bt + +import ( + "sync" + "time" +) + +// Breadcrumb levels understood by the Backtrace UI. +const ( + BreadcrumbDebug = "debug" + BreadcrumbInfo = "info" + BreadcrumbWarning = "warning" + BreadcrumbError = "error" +) + +// Breadcrumb is a lightweight trail entry recorded before an error occurs. +// Breadcrumbs are attached to every report as the "breadcrumbs" annotation. +type Breadcrumb struct { + // Timestamp in Unix milliseconds. Filled automatically by AddBreadcrumb + // when zero. + Timestamp int64 `json:"timestamp"` + + // ID is a monotonically increasing sequence number. + ID uint64 `json:"id"` + + // Level is one of the Breadcrumb* constants; defaults to "info". + Level string `json:"level"` + + // Type categorizes the breadcrumb ("manual", "http", "log", ...); + // defaults to "manual". + Type string `json:"type"` + + // Message is the human-readable description. + Message string `json:"message"` + + // Attributes carry optional structured metadata. + Attributes map[string]interface{} `json:"attributes,omitempty"` +} + +// breadcrumbRing is a fixed-capacity, concurrency-safe ring buffer. +type breadcrumbRing struct { + mu sync.Mutex + buf []Breadcrumb + next uint64 // sequence counter + head int // index of oldest element + size int // number of stored elements +} + +func newBreadcrumbRing(capacity int) *breadcrumbRing { + if capacity <= 0 { + return nil + } + return &breadcrumbRing{buf: make([]Breadcrumb, capacity)} +} + +func (r *breadcrumbRing) add(b Breadcrumb) { + if r == nil { + return + } + if b.Timestamp == 0 { + b.Timestamp = time.Now().UnixMilli() + } + if b.Level == "" { + b.Level = BreadcrumbInfo + } + if b.Type == "" { + b.Type = "manual" + } + + r.mu.Lock() + defer r.mu.Unlock() + b.ID = r.next + r.next++ + if r.size < len(r.buf) { + r.buf[(r.head+r.size)%len(r.buf)] = b + r.size++ + return + } + // Full: overwrite the oldest entry. + r.buf[r.head] = b + r.head = (r.head + 1) % len(r.buf) +} + +// snapshot returns breadcrumbs ordered oldest to newest. +func (r *breadcrumbRing) snapshot() []Breadcrumb { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + if r.size == 0 { + return nil + } + out := make([]Breadcrumb, r.size) + for i := 0; i < r.size; i++ { + out[i] = r.buf[(r.head+i)%len(r.buf)] + } + return out +} diff --git a/bthttp/bthttp.go b/bthttp/bthttp.go new file mode 100644 index 0000000..8cf2593 --- /dev/null +++ b/bthttp/bthttp.go @@ -0,0 +1,99 @@ +// Package bthttp provides net/http middleware that reports panics in HTTP +// handlers to Backtrace, enriched with request attributes. +// +// handler := bthttp.New(bthttp.Options{Repanic: true}).Handle(mux) +// http.ListenAndServe(":8080", handler) +// +// Reports are sent through the client configured via bt.Options, or through +// Options.Client when set. +package bthttp + +import ( + "net/http" + "time" + + bt "github.com/backtrace-labs/backtrace-go" +) + +// Options configures the middleware. +type Options struct { + // Repanic re-raises the panic after reporting so outer middleware or + // the net/http server recovery can run. When false the panic is + // swallowed and the connection is left to net/http's default + // handling of an aborted handler. + Repanic bool + + // WaitForDelivery blocks the failing request until the report is + // delivered (bounded by FlushTimeout) instead of returning + // immediately. Recommended when Repanic is true and the process may + // terminate. + WaitForDelivery bool + + // FlushTimeout bounds WaitForDelivery. Default: 2s. + FlushTimeout time.Duration + + // Client sends reports through a specific bt.Client instead of the + // global reporter. + Client *bt.Client +} + +// Handler wraps HTTP handlers with panic reporting. +type Handler struct { + opts Options +} + +// New creates a middleware Handler with the given options. +func New(opts Options) *Handler { + if opts.FlushTimeout <= 0 { + opts.FlushTimeout = 2 * time.Second + } + return &Handler{opts: opts} +} + +// Handle wraps next with panic reporting. +func (h *Handler) Handle(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer h.recoverAndReport(r) + next.ServeHTTP(w, r) + }) +} + +// HandleFunc wraps next with panic reporting. +func (h *Handler) HandleFunc(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer h.recoverAndReport(r) + next(w, r) + } +} + +func (h *Handler) recoverAndReport(r *http.Request) { + v := recover() + if v == nil { + return + } + + attrs := map[string]interface{}{ + "request.url": r.URL.Path, + "request.method": r.Method, + "request.host": r.Host, + "request.remote_addr": r.RemoteAddr, + "request.user_agent": r.UserAgent(), + "request.proto": r.Proto, + } + + if h.opts.Client != nil { + h.opts.Client.ReportPanicValue(v, attrs) + if h.opts.WaitForDelivery { + h.opts.Client.Flush(h.opts.FlushTimeout) + } + } else { + bt.ReportPanicValue(v, attrs) + if h.opts.WaitForDelivery { + bt.Flush(h.opts.FlushTimeout) + } + } + + if h.opts.Repanic { + panic(v) + } +} diff --git a/bthttp/bthttp_test.go b/bthttp/bthttp_test.go new file mode 100644 index 0000000..4e4370f --- /dev/null +++ b/bthttp/bthttp_test.go @@ -0,0 +1,150 @@ +package bthttp + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + bt "github.com/backtrace-labs/backtrace-go" +) + +type capture struct { + mu sync.Mutex + payloads []map[string]interface{} +} + +func (c *capture) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.payloads) +} + +func (c *capture) last() map[string]interface{} { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.payloads) == 0 { + return nil + } + return c.payloads[len(c.payloads)-1] +} + +func newCaptureClient(t *testing.T) (*bt.Client, *capture) { + t.Helper() + cap := &capture{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + payload := map[string]interface{}{} + if json.Unmarshal(body, &payload) == nil { + cap.mu.Lock() + cap.payloads = append(cap.payloads, payload) + cap.mu.Unlock() + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + client, err := bt.NewClient(bt.Config{Endpoint: srv.URL, Token: "middleware-test"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(client.Close) + return client, cap +} + +func TestMiddlewareReportsPanicsWithRequestAttributes(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("handler exploded") + })) + + req := httptest.NewRequest(http.MethodPost, "/checkout?item=1", nil) + req.Header.Set("User-Agent", "bthttp-test-agent") + wrapped.ServeHTTP(httptest.NewRecorder(), req) + + if cap.count() != 1 { + t.Fatalf("reports = %d, want 1", cap.count()) + } + attrs, _ := cap.last()["attributes"].(map[string]interface{}) + if attrs["error.message"] != "handler exploded" { + t.Errorf("error.message = %v", attrs["error.message"]) + } + if attrs["request.url"] != "/checkout" || attrs["request.method"] != "POST" { + t.Errorf("request attributes wrong: url=%v method=%v", attrs["request.url"], attrs["request.method"]) + } + if attrs["request.user_agent"] != "bthttp-test-agent" { + t.Errorf("request.user_agent = %v", attrs["request.user_agent"]) + } + if attrs["report_type"] != "panic" { + t.Errorf("report_type = %v", attrs["report_type"]) + } +} + +func TestMiddlewareNoPanicPassthrough(t *testing.T) { + client, cap := newCaptureClient(t) + + var served bool + h := New(Options{Client: client}) + wrapped := h.HandleFunc(func(w http.ResponseWriter, r *http.Request) { + served = true + w.WriteHeader(http.StatusTeapot) + }) + + rec := httptest.NewRecorder() + wrapped(rec, httptest.NewRequest(http.MethodGet, "/ok", nil)) + + if !served || rec.Code != http.StatusTeapot { + t.Error("handler not executed normally") + } + client.Flush(2 * time.Second) + if cap.count() != 0 { + t.Errorf("healthy request produced %d reports", cap.count()) + } +} + +func TestMiddlewareRepanic(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, Repanic: true, WaitForDelivery: true, FlushTimeout: 5 * time.Second}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("must propagate") + })) + + didPanic := false + func() { + defer func() { + if recover() != nil { + didPanic = true + } + }() + wrapped.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + }() + + if !didPanic { + t.Error("Repanic: panic was swallowed") + } + if cap.count() != 1 { + t.Errorf("reports = %d, want 1", cap.count()) + } +} + +func TestMiddlewareSwallowsWithoutRepanic(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("swallowed") + })) + + // Must not panic. + wrapped.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + + if cap.count() != 1 { + t.Errorf("reports = %d, want 1", cap.count()) + } +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..9e4a2e8 --- /dev/null +++ b/client.go @@ -0,0 +1,477 @@ +package bt + +import ( + "encoding/json" + "fmt" + "math/rand/v2" + "runtime" + "sync" + "sync/atomic" + "time" +) + +// Client is an instance-based Backtrace reporter. Multiple independent +// clients may coexist in one process. All methods are safe for concurrent +// use, never block the caller on network I/O, and never panic. +// +// Reports are queued to a background worker; when the queue is full new +// reports are dropped and counted (see DroppedReports) instead of blocking. +// Call Flush to wait for delivery of queued reports and Close to shut the +// client down. +type Client struct { + // cfgFn returns the normalized configuration. For clients created by + // NewClient it returns a fixed config; the legacy global client + // re-reads bt.Options so that historical mutate-the-global usage + // keeps working. + cfgFn func() Config + + transport *httpTransport + + queue chan clientJob + qmu sync.RWMutex // guards closed + sends into queue + closed bool + workerDone chan struct{} + + amu sync.Mutex // guards attributes + attributes map[string]interface{} + + crumbs *breadcrumbRing + + dropped atomic.Uint64 +} + +// clientJob is either a queued report (data != nil) or a flush marker. +type clientJob struct { + data *queuedReport + flush chan struct{} +} + +// queuedReport carries everything captured on the caller's goroutine; +// parsing and I/O happen on the worker. +type queuedReport struct { + stack []byte + attributes map[string]interface{} + annotations map[string]interface{} + classifiers []string + timestamp int64 +} + +// NewClient creates and starts a reporter with the given configuration. +// It returns an error when the endpoint is missing or unparsable. +func NewClient(cfg Config) (*Client, error) { + n := cfg.normalize() + if err := n.validate(); err != nil { + return nil, err + } + return startClient(func() Config { return n }), nil +} + +// startClient wires up a client around a config source and starts its worker. +func startClient(cfgFn func() Config) *Client { + cfg := cfgFn() + c := &Client{ + cfgFn: cfgFn, + transport: newHTTPTransport(cfg.HTTPClient, cfg.Timeout), + queue: make(chan clientJob, cfg.QueueSize), + workerDone: make(chan struct{}), + attributes: map[string]interface{}{}, + crumbs: newBreadcrumbRing(cfg.MaxBreadcrumbs), + } + go c.worker() + return c +} + +func (c *Client) diag() diag { + cfg := c.cfgFn() + return diag{logger: cfg.Logger, debug: cfg.Debug} +} + +// Report sends an error report. object may be an error (its message, +// type and unwrap chain are captured) or any value convertible to a string +// (reported as a message). A nil object is ignored. extraAttributes are +// added to this report only; the map is not retained or mutated. +func (c *Client) Report(object interface{}, extraAttributes map[string]interface{}) { + switch v := object.(type) { + case nil: + return + case error: + c.ReportError(v, extraAttributes) + default: + c.ReportMessage(fmt.Sprint(v), extraAttributes) + } +} + +// ReportError sends a report for err, capturing its type and unwrap chain. +func (c *Client) ReportError(err error, extraAttributes map[string]interface{}) { + if err == nil { + return + } + c.capture(captureInput{ + message: err.Error(), + err: err, + classifier: "error", + reportType: "error", + extra: extraAttributes, + }) +} + +// ReportMessage sends a plain message report. +func (c *Client) ReportMessage(msg string, extraAttributes map[string]interface{}) { + c.capture(captureInput{ + message: msg, + classifier: "message", + reportType: "message", + extra: extraAttributes, + }) +} + +// ReportPanicValue sends a report for a recovered panic value. It does not +// call recover itself and does not re-panic; it is intended for middleware +// and custom panic handlers. Call Flush afterwards when the process (or +// goroutine) is about to die. +// +// Unlike regular reports, a panic report retries a full queue for up to +// DefaultFlushTimeout before being dropped: it is likely the process's +// last report. +func (c *Client) ReportPanicValue(value interface{}, extraAttributes map[string]interface{}) { + if value == nil { + return + } + in := captureInput{ + message: fmt.Sprint(value), + classifier: "panic", + reportType: "panic", + extra: extraAttributes, + enqueueWait: DefaultFlushTimeout, + } + if err, ok := value.(error); ok { + in.err = err + } + c.capture(in) +} + +// SetAttribute sets a client-wide attribute included in every subsequent +// report. Safe for concurrent use. +func (c *Client) SetAttribute(key string, value interface{}) { + c.amu.Lock() + defer c.amu.Unlock() + c.attributes[key] = value +} + +// SetAttributes sets multiple client-wide attributes atomically. +func (c *Client) SetAttributes(attrs map[string]interface{}) { + c.amu.Lock() + defer c.amu.Unlock() + for k, v := range attrs { + c.attributes[k] = v + } +} + +// AddBreadcrumb records a breadcrumb attached to every subsequent report as +// part of the "breadcrumbs" annotation. Safe for concurrent use. +func (c *Client) AddBreadcrumb(b Breadcrumb) { + c.crumbs.add(b) +} + +// DroppedReports returns the number of reports dropped because the queue was +// full, the client was closed, or delivery failed. +func (c *Client) DroppedReports() uint64 { + return c.dropped.Load() +} + +// flushPollInterval paces retries when the queue is too full to accept a +// flush marker or a panic report immediately. +const flushPollInterval = 10 * time.Millisecond + +// Flush blocks until all reports queued at the time of the call have been +// processed, or until timeout elapses. It reports whether the drain +// completed in time. Unlike the legacy FinishSendingReports, Flush never +// stops the worker: the client remains fully usable afterwards. +func (c *Client) Flush(timeout time.Duration) bool { + marker := make(chan struct{}) + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + c.qmu.RLock() + if c.closed { + c.qmu.RUnlock() + // Close drains the queue; wait for the worker to + // finish, bounded by the timeout. + select { + case <-c.workerDone: + return true + case <-timer.C: + return false + } + } + // Non-blocking attempt only: holding qmu across a blocking + // send would stall every Report() caller behind a queued + // Close (RWMutex writer preference). + select { + case c.queue <- clientJob{flush: marker}: + c.qmu.RUnlock() + select { + case <-marker: + return true + case <-timer.C: + return false + } + default: + } + c.qmu.RUnlock() + + select { + case <-timer.C: + return false + case <-time.After(flushPollInterval): + } + } +} + +// Close drains the queue, stops the worker, and releases the client. +// Subsequent reports are dropped (and counted). Close is idempotent. +// Call Flush first if you need a bounded wait; Close waits for the full +// drain (each send is bounded by the configured timeout). +func (c *Client) Close() { + c.qmu.Lock() + if !c.closed { + c.closed = true + close(c.queue) + } + c.qmu.Unlock() + <-c.workerDone +} + +// captureInput bundles the per-call capture parameters. +type captureInput struct { + message string + err error + classifier string + reportType string + extra map[string]interface{} + // enqueueWait bounds how long a full queue is retried before the + // report is dropped; zero means drop immediately (never block). + enqueueWait time.Duration +} + +// capture assembles everything that must be observed on the caller's +// goroutine (stack, attribute snapshot) and enqueues the report. It never +// blocks on the queue and never panics. +func (c *Client) capture(in captureInput) { + defer c.recoverInternal("capture") + + cfg := c.cfgFn() + + if cfg.SampleRate < 1 && rand.Float64() >= cfg.SampleRate { + c.diag().logf("report sampled out (SampleRate=%v)", cfg.SampleRate) + return + } + + attributes := map[string]interface{}{} + for k, v := range staticAttributes() { + attributes[k] = v + } + updateAttrsWithProcMemInfo(attributes, c.diag()) + runtimeAttributes(attributes) + + // Config-level attributes (treated as read-only after NewClient). + for k, v := range cfg.Attributes { + attributes[k] = v + } + // Client-wide attributes set via SetAttribute. + c.amu.Lock() + for k, v := range c.attributes { + attributes[k] = v + } + c.amu.Unlock() + + attributes["error.message"] = in.message + attributes["report_type"] = in.reportType + + classifiers := []string{in.classifier} + annotations := map[string]interface{}{} + + if in.err != nil { + chain := unwrapErrorChain(in.err, cfg.MaxErrorDepth) + if len(chain) > 0 { + attributes["error.type"] = chain[0].Type + annotations["Error Chain"] = chain + } + } + + // Per-call attributes win over everything; the caller's map is copied, + // never retained or mutated. + for k, v := range in.extra { + attributes[k] = v + } + + if cfg.SendEnvVars { + annotations["Environment Variables"] = getEnvVars(cfg.ScrubEnvVars) + } + if crumbs := c.crumbs.snapshot(); len(crumbs) > 0 { + annotations["breadcrumbs"] = crumbs + } + + c.enqueue(clientJob{data: &queuedReport{ + stack: captureStack(cfg.CaptureAllGoroutines), + attributes: attributes, + annotations: annotations, + classifiers: classifiers, + timestamp: time.Now().Unix(), + }}, in.enqueueWait) +} + +// enqueue queues a job without ever holding qmu across a blocking send. +// With wait <= 0 a full queue drops the report immediately (regular +// reports never block the caller). A positive wait — used for panic +// reports, which are the process's last words — retries for up to that +// duration before dropping. +func (c *Client) enqueue(j clientJob, wait time.Duration) { + deadline := time.Now().Add(wait) + for { + c.qmu.RLock() + if c.closed { + c.qmu.RUnlock() + c.dropped.Add(1) + c.diag().logf("report dropped: client closed") + return + } + select { + case c.queue <- j: + c.qmu.RUnlock() + return + default: + } + c.qmu.RUnlock() + + if wait <= 0 || !time.Now().Before(deadline) { + c.dropped.Add(1) + c.diag().logf("report dropped: queue full (capacity %d)", cap(c.queue)) + return + } + time.Sleep(flushPollInterval) + } +} + +// worker is the single consumer of the queue. It exits when Close closes the +// queue, after draining remaining jobs. +func (c *Client) worker() { + defer close(c.workerDone) + for j := range c.queue { + if j.flush != nil { + close(j.flush) + continue + } + c.processAndSend(j.data) + } +} + +// processAndSend turns a queued report into the wire payload and delivers +// it. Runs on the worker goroutine; all failure modes degrade to a debug log +// plus the dropped counter. +func (c *Client) processAndSend(qr *queuedReport) { + defer c.recoverInternal("processAndSend") + + cfg := c.cfgFn() + d := diag{logger: cfg.Logger, debug: cfg.Debug} + + // Machine and build metadata are gathered lazily (never at import + // time) and merged without overriding caller-provided values. + if !cfg.DisableMachineAttributes { + for k, v := range machineAttributes(d) { + if _, exists := qr.attributes[k]; !exists { + qr.attributes[k] = v + } + } + } + biAttrs, modules := buildInfoAttributes() + for k, v := range biAttrs { + if _, exists := qr.attributes[k]; !exists { + qr.attributes[k] = v + } + } + if len(modules) > 0 { + if _, exists := qr.annotations["Dependencies"]; !exists { + qr.annotations["Dependencies"] = modules + } + } + + threads, sourceCode, mainThread := buildThreads(qr.stack, sourceOptions{ + mode: cfg.SourceCode, + contextLines: cfg.ContextLineCount, + tabWidth: cfg.TabWidth, + }) + + report := &ReportData{ + UUID: uuid4(), + Timestamp: qr.timestamp, + Classifiers: qr.classifiers, + Attributes: qr.attributes, + Annotations: qr.annotations, + Threads: threads, + SourceCode: sourceCode, + MainThread: mainThread, + Attachments: append([]string(nil), cfg.AttachmentPaths...), + } + + if cfg.BeforeSend != nil { + if modified := c.runBeforeSend(cfg.BeforeSend, report, d); modified == nil { + d.logf("report %s dropped by BeforeSend", report.UUID) + return + } else { + report = modified + } + } + + body, err := json.Marshal(report.toWire()) + if err != nil { + c.dropped.Add(1) + d.logf("report %s dropped: marshal failed: %v", report.UUID, err) + return + } + + if cfg.Debug { + pretty, _ := json.MarshalIndent(report.toWire(), "", " ") + d.logf("sending report %s to %s\n%s", report.UUID, redactURL(cfg.submissionURL()), pretty) + } + + if err := c.transport.send(cfg.submissionURL(), body, report.Attachments, d); err != nil { + c.dropped.Add(1) + d.logf("report %s dropped: %v", report.UUID, err) + } +} + +// runBeforeSend isolates user hook panics from the worker. +func (c *Client) runBeforeSend(hook func(*ReportData) *ReportData, report *ReportData, d diag) (out *ReportData) { + defer func() { + if r := recover(); r != nil { + d.logf("BeforeSend panicked (%v); sending report unmodified", r) + out = report + } + }() + return hook(report) +} + +// recoverInternal is the last line of defense: an SDK bug must never crash +// the host application. +func (c *Client) recoverInternal(where string) { + if r := recover(); r != nil { + buf := make([]byte, 4096) + n := runtime.Stack(buf, false) + c.diag().logf("internal error in %s (please report to backtrace-labs/backtrace-go): %v\n%s", where, r, buf[:n]) + } +} + +// captureStack returns the formatted stack trace of the calling goroutine, +// or of all goroutines when all is true. +func captureStack(all bool) []byte { + buf := make([]byte, 1024) + for { + n := runtime.Stack(buf, all) + if n < len(buf) { + return buf[:n] + } + buf = make([]byte, 2*len(buf)) + } +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..3f6e871 --- /dev/null +++ b/client_test.go @@ -0,0 +1,458 @@ +package bt + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func newTestClient(t *testing.T, mutate func(*Config)) (*Client, *recordingServer) { + t.Helper() + rs := newRecordingServer() + t.Cleanup(rs.srv.Close) + + cfg := Config{ + Endpoint: rs.srv.URL, + Token: "client-test-token", + Timeout: 5 * time.Second, + } + if mutate != nil { + mutate(&cfg) + } + c, err := NewClient(cfg) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(c.Close) + return c, rs +} + +func TestNewClientValidation(t *testing.T) { + // Neutralize any BACKTRACE_* variables from the outer environment. + t.Setenv(envEndpoint, "") + t.Setenv(envToken, "") + + if _, err := NewClient(Config{}); err == nil { + t.Error("expected error for missing endpoint") + } + if _, err := NewClient(Config{Endpoint: "ftp://example.com"}); err == nil { + t.Error("expected error for non-http scheme") + } + if _, err := NewClient(Config{Endpoint: "http://example.com"}); err != nil { + t.Errorf("valid config rejected: %v", err) + } +} + +func TestClientReportDelivery(t *testing.T) { + c, rs := newTestClient(t, nil) + + c.Report(errors.New("client error"), map[string]interface{}{"who": "client"}) + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + if rs.count() != 1 { + t.Fatalf("got %d reports, want 1", rs.count()) + } + attrs := attrsOf(t, rs.last()) + if attrs["error.message"] != "client error" || attrs["who"] != "client" { + t.Errorf("unexpected attributes: %v", attrs) + } + + // Context-mode source: this test file's frames are SDK-prefix-filtered + // (the test lives in the SDK package), so the surviving frames come + // from the testing package and snippets are read from GOROOT sources. + sources, _ := rs.last()["sourceCode"].(map[string]interface{}) + foundSnippet := false + for _, v := range sources { + sc, _ := v.(map[string]interface{}) + if sc == nil { + continue + } + if path, _ := sc["path"].(string); strings.HasSuffix(path, "client_test.go") { + t.Errorf("SDK-package frame not filtered: %q", path) + } + if text, _ := sc["text"].(string); text != "" { + if start, _ := sc["startLine"].(float64); start >= 1 { + foundSnippet = true + } + if len(text) > 1<<16 { + t.Errorf("context snippet suspiciously large (%d bytes): whole file embedded?", len(text)) + } + } + } + if !foundSnippet { + t.Error("no source context snippet found in payload") + } +} + +func TestTwoClientsCoexist(t *testing.T) { + c1, rs1 := newTestClient(t, nil) + c2, rs2 := newTestClient(t, nil) + + c1.ReportMessage("to first", nil) + c2.ReportMessage("to second", nil) + c1.Flush(5 * time.Second) + c2.Flush(5 * time.Second) + + if rs1.count() != 1 || rs2.count() != 1 { + t.Fatalf("cross-talk between clients: rs1=%d rs2=%d", rs1.count(), rs2.count()) + } +} + +func TestQueueOverflowDoesNotBlockCaller(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, func(cfg *Config) { + cfg.QueueSize = 2 + }) + // Guarantee the handler is unblocked on every exit path — otherwise a + // test failure would hang the deferred server Close. + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 20; i++ { + c.ReportMessage(fmt.Sprintf("burst %d", i), nil) + } + }() + + select { + case <-done: + // Callers returned immediately even though the transport is stuck. + case <-time.After(3 * time.Second): + t.Fatal("Report blocked the caller with a full queue") + } + + if c.DroppedReports() == 0 { + t.Error("expected dropped reports with a full queue") + } + + rs.mu.Lock() + rs.block = nil + rs.mu.Unlock() + unblock() + c.Flush(5 * time.Second) +} + +func TestFlushDoesNotStopWorker(t *testing.T) { + c, rs := newTestClient(t, nil) + + c.ReportMessage("first", nil) + if !c.Flush(5 * time.Second) { + t.Fatal("first Flush timed out") + } + c.ReportMessage("second", nil) + if !c.Flush(5 * time.Second) { + t.Fatal("second Flush timed out") + } + if rs.count() != 2 { + t.Fatalf("got %d reports, want 2", rs.count()) + } +} + +func TestFlushTimesOutWhenTransportStuck(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, nil) + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + c.ReportMessage("stuck", nil) + if c.Flush(100 * time.Millisecond) { + t.Error("Flush reported success while transport is stuck") + } + unblock() + c.Flush(5 * time.Second) +} + +func TestCloseDrainsAndIsIdempotent(t *testing.T) { + c, rs := newTestClient(t, nil) + + c.ReportMessage("before close", nil) + c.Close() + c.Close() // must not panic or deadlock + + if rs.count() != 1 { + t.Fatalf("Close did not drain the queue: %d reports", rs.count()) + } + + dropped := c.DroppedReports() + c.ReportMessage("after close", nil) + if c.DroppedReports() != dropped+1 { + t.Error("report after Close was not counted as dropped") + } +} + +func TestBeforeSendMutates(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.BeforeSend = func(r *ReportData) *ReportData { + r.Attributes["scrubbed"] = true + return r + } + }) + c.ReportMessage("hello", nil) + c.Flush(5 * time.Second) + + if attrs := attrsOf(t, rs.last()); attrs["scrubbed"] != true { + t.Errorf("BeforeSend mutation lost: %v", attrs["scrubbed"]) + } +} + +func TestBeforeSendDrops(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.BeforeSend = func(r *ReportData) *ReportData { return nil } + }) + c.ReportMessage("dropped", nil) + c.Flush(5 * time.Second) + + if rs.count() != 0 { + t.Fatalf("BeforeSend nil did not drop the report") + } +} + +func TestBeforeSendPanicIsContained(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.BeforeSend = func(r *ReportData) *ReportData { panic("hook bug") } + }) + c.ReportMessage("survives", nil) + c.Flush(5 * time.Second) + + if rs.count() != 1 { + t.Fatalf("report lost to BeforeSend panic: %d", rs.count()) + } +} + +func TestSampleRate(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.SampleRate = 0.000001 + }) + for i := 0; i < 50; i++ { + c.ReportMessage("sampled", nil) + } + c.Flush(5 * time.Second) + + if rs.count() > 5 { + t.Errorf("sampling ineffective: %d of 50 delivered at rate 1e-6", rs.count()) + } +} + +func TestSampleRateZeroValueSendsEverything(t *testing.T) { + cfg := Config{Endpoint: "http://example.com"}.normalize() + if cfg.SampleRate != 1.0 { + t.Errorf("zero-value SampleRate = %v, want 1.0", cfg.SampleRate) + } +} + +func TestErrorChainCapture(t *testing.T) { + c, rs := newTestClient(t, nil) + + inner := errors.New("root cause") + middle := fmt.Errorf("middle: %w", inner) + outer := fmt.Errorf("outer: %w", middle) + c.Report(outer, nil) + c.Flush(5 * time.Second) + + attrs := attrsOf(t, rs.last()) + if attrs["error.type"] != "*fmt.wrapError" { + t.Errorf("error.type = %v", attrs["error.type"]) + } + annotations, _ := rs.last()["annotations"].(map[string]interface{}) + chain, _ := annotations["Error Chain"].([]interface{}) + if len(chain) != 3 { + t.Fatalf("error chain length = %d, want 3", len(chain)) + } + last, _ := chain[2].(map[string]interface{}) + if last["message"] != "root cause" { + t.Errorf("chain tail = %v", last) + } +} + +func TestBreadcrumbsRingAndAnnotation(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.MaxBreadcrumbs = 8 + }) + for i := 0; i < 12; i++ { + c.AddBreadcrumb(Breadcrumb{Message: fmt.Sprintf("crumb %d", i)}) + } + c.ReportMessage("with crumbs", nil) + c.Flush(5 * time.Second) + + annotations, _ := rs.last()["annotations"].(map[string]interface{}) + crumbs, _ := annotations["breadcrumbs"].([]interface{}) + if len(crumbs) != 8 { + t.Fatalf("breadcrumb count = %d, want 8 (ring capacity)", len(crumbs)) + } + first, _ := crumbs[0].(map[string]interface{}) + if first["message"] != "crumb 4" { + t.Errorf("oldest breadcrumb = %v, want crumb 4 (eviction order)", first["message"]) + } + if first["level"] != "info" || first["type"] != "manual" { + t.Errorf("breadcrumb defaults not applied: %v", first) + } +} + +func TestServerErrorCountsAsDropped(t *testing.T) { + c, rs := newTestClient(t, nil) + rs.mu.Lock() + rs.status = http.StatusInternalServerError + rs.mu.Unlock() + + c.ReportMessage("rejected", nil) + c.Flush(5 * time.Second) + + if c.DroppedReports() != 1 { + t.Errorf("5xx not counted as dropped: %d", c.DroppedReports()) + } +} + +func TestRateLimit429PausesSubmissions(t *testing.T) { + c, rs := newTestClient(t, nil) + rs.mu.Lock() + rs.status = http.StatusTooManyRequests + rs.mu.Unlock() + + c.ReportMessage("first", nil) + c.Flush(5 * time.Second) + + received := rs.count() // server saw the 429'd request + c.ReportMessage("second", nil) + c.Flush(5 * time.Second) + + if rs.count() != received { + t.Error("submission was not paused after 429") + } + if c.DroppedReports() < 2 { + t.Errorf("dropped counter = %d, want >= 2", c.DroppedReports()) + } +} + +func TestClientConcurrencySafety(t *testing.T) { + c, _ := newTestClient(t, func(cfg *Config) { + cfg.QueueSize = 4 + }) + + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 25; i++ { + c.SetAttribute("g", g) + c.AddBreadcrumb(Breadcrumb{Message: "b"}) + c.Report(errors.New("concurrent"), nil) + c.Flush(10 * time.Millisecond) + } + }(g) + } + wg.Wait() + c.Flush(5 * time.Second) +} + +func TestAttachmentsMultipartSubmission(t *testing.T) { + dir := t.TempDir() + attachmentPath := filepath.Join(dir, "app.log") + if err := os.WriteFile(attachmentPath, []byte("log line 1\nlog line 2\n"), 0o644); err != nil { + t.Fatal(err) + } + + type received struct { + reportJSON map[string]interface{} + attachments map[string]string + } + got := make(chan received, 1) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + t.Errorf("expected multipart submission: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + rec := received{attachments: map[string]string{}} + for field, headers := range r.MultipartForm.File { + f, err := headers[0].Open() + if err != nil { + continue + } + content, _ := io.ReadAll(f) + f.Close() + if field == "upload_file" { + payload := map[string]interface{}{} + if json.Unmarshal(content, &payload) == nil { + rec.reportJSON = payload + } + } else { + rec.attachments[field] = string(content) + } + } + got <- rec + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c, err := NewClient(Config{ + Endpoint: srv.URL, + Token: "attach-test", + AttachmentPaths: []string{attachmentPath, filepath.Join(dir, "missing.txt")}, + }) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + c.ReportMessage("with attachment", nil) + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + select { + case rec := <-got: + if rec.reportJSON == nil { + t.Fatal("upload_file part missing or not JSON") + } + if rec.reportJSON["lang"] != "go" { + t.Errorf("report JSON lang = %v", rec.reportJSON["lang"]) + } + if rec.attachments["attachment_app.log"] != "log line 1\nlog line 2\n" { + t.Errorf("attachment content = %q", rec.attachments["attachment_app.log"]) + } + if len(rec.attachments) != 1 { + t.Errorf("unreadable attachment not skipped: %v", rec.attachments) + } + case <-time.After(time.Second): + t.Fatal("no submission received") + } +} + +func TestUUID4Format(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 1000; i++ { + u := uuid4() + if len(u) != 36 || u[14] != '4' { + t.Fatalf("bad uuid: %q", u) + } + if seen[u] { + t.Fatalf("duplicate uuid generated: %q", u) + } + seen[u] = true + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..9669f2c --- /dev/null +++ b/config.go @@ -0,0 +1,233 @@ +package bt + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// SourceCodeMode controls how much application source code is embedded in +// reports for the Backtrace debugger's source view. +type SourceCodeMode string + +const ( + // SourceCodeContext embeds only ContextLineCount lines around each + // stack frame. This is the default: it keeps payloads small and avoids + // shipping whole files off the host. + SourceCodeContext SourceCodeMode = "context" + + // SourceCodeFile embeds the entire source file referenced by each + // frame (the SDK's historical behavior). Opt-in. + SourceCodeFile SourceCodeMode = "file" + + // SourceCodeNone disables source code capture entirely. + SourceCodeNone SourceCodeMode = "none" +) + +// Defaults applied by NewClient when the corresponding Config field is zero. +const ( + // DefaultTimeout is the per-request HTTP timeout. + DefaultTimeout = 30 * time.Second + + // DefaultQueueSize is the capacity of the in-memory report queue. + // When the queue is full new reports are dropped (never blocking the + // caller) and counted; see Client.DroppedReports. + DefaultQueueSize = 128 + + // DefaultContextLineCount is the number of source lines captured + // above and below a stack frame line in SourceCodeContext mode. + DefaultContextLineCount = 8 + + // DefaultMaxErrorDepth caps how many wrapped errors are walked when + // capturing an error chain. + DefaultMaxErrorDepth = 100 + + // DefaultMaxBreadcrumbs is the capacity of the breadcrumb ring buffer. + DefaultMaxBreadcrumbs = 64 + + // DefaultTabWidth is reported to the Backtrace UI for source rendering. + DefaultTabWidth = 8 + + // DefaultFlushTimeout is used by panic handlers and the legacy + // FinishSendingReports to bound how long delivery is awaited. + DefaultFlushTimeout = 5 * time.Second +) + +// Environment variables consulted when the corresponding Config field is empty. +const ( + envEndpoint = "BACKTRACE_ENDPOINT" + envToken = "BACKTRACE_TOKEN" +) + +// Config configures a Client. The zero value is not usable: Endpoint is +// required (directly or via the BACKTRACE_ENDPOINT environment variable). +type Config struct { + // Endpoint is the Backtrace submission URL. Two forms are supported: + // + // https://submit.backtrace.io/{universe}/{token}/json (full URL, leave Token empty) + // https://{universe}.sp.backtrace.io (paired with Token) + // + // When Token is set, "/post?format=json&token=..." is appended. + // Falls back to the BACKTRACE_ENDPOINT environment variable. + Endpoint string + + // Token is the project submission token for legacy endpoints. Leave + // empty when Endpoint is a full submit.backtrace.io URL. + // Falls back to the BACKTRACE_TOKEN environment variable. + Token string + + // CaptureAllGoroutines includes every goroutine's stack in reports, + // not just the calling goroutine's. + CaptureAllGoroutines bool + + // SourceCode controls source code embedding. Default: SourceCodeContext. + SourceCode SourceCodeMode + + // ContextLineCount is the number of lines captured above and below a + // frame's line in SourceCodeContext mode. Default: 8. + ContextLineCount int + + // TabWidth is reported to the Backtrace UI for source rendering. Default: 8. + TabWidth int + + // Attributes are added to every report sent by the client. + Attributes map[string]interface{} + + // SendEnvVars attaches the process environment to every report as an + // annotation. Values of variables whose names look secret-bearing + // (TOKEN, SECRET, PASSWORD, KEY, ...) are redacted; see ScrubEnvVars. + SendEnvVars bool + + // ScrubEnvVars adds case-insensitive substrings to the built-in list + // of environment variable name patterns whose values are redacted. + ScrubEnvVars []string + + // AttachmentPaths lists files attached to every report (multipart + // submission, one "attachment_" part per file). Unreadable + // files are skipped with a debug log. Per-report changes can be made + // in BeforeSend via ReportData.Attachments. + AttachmentPaths []string + + // SampleRate is the fraction of reports actually sent, in [0.0, 1.0]. + // The zero value means 1.0 (send everything), so an uninitialized + // Config never silently drops reports. + SampleRate float64 + + // BeforeSend, when set, runs just before a report is serialized. + // Return the (optionally modified) report to send it, or nil to drop + // it. Runs on the SDK's worker goroutine; a panic inside the hook is + // recovered and logged, and the report is sent unmodified. + BeforeSend func(report *ReportData) *ReportData + + // MaxErrorDepth caps error-chain unwrapping. Default: 100. Negative + // disables chain capture. + MaxErrorDepth int + + // MaxBreadcrumbs caps the breadcrumb ring buffer. Default: 64. + // Negative disables breadcrumbs. + MaxBreadcrumbs int + + // QueueSize is the report queue capacity. Default: 128. + QueueSize int + + // Timeout is the per-request HTTP timeout. Default: 30s. + Timeout time.Duration + + // HTTPClient overrides the HTTP client used for submission. When set, + // Timeout is not applied to it; configure the client yourself. + HTTPClient *http.Client + + // DisableMachineAttributes skips the exec-based collection of machine + // metadata (CPU model, OS version, machine GUID). Useful in minimal + // containers without a shell. + DisableMachineAttributes bool + + // Debug enables SDK diagnostic logging (report payloads, delivery + // errors, drops). The SDK never panics regardless of this setting. + Debug bool + + // Logger receives diagnostic output when Debug is on. + // Default: log.New(os.Stderr, "[backtrace] ", log.LstdFlags). + Logger Logger +} + +// normalize applies defaults and environment fallbacks. It does not mutate c. +func (c Config) normalize() Config { + if c.Endpoint == "" { + c.Endpoint = os.Getenv(envEndpoint) + } + if c.Token == "" { + c.Token = os.Getenv(envToken) + } + if c.SourceCode == "" { + c.SourceCode = SourceCodeContext + } + if c.ContextLineCount <= 0 { + c.ContextLineCount = DefaultContextLineCount + } + if c.TabWidth <= 0 { + c.TabWidth = DefaultTabWidth + } + if c.SampleRate <= 0 { + // Zero value means "send everything" so that a Config that never + // mentions sampling behaves as expected. + c.SampleRate = 1.0 + } + if c.SampleRate > 1 { + c.SampleRate = 1.0 + } + if c.MaxErrorDepth == 0 { + c.MaxErrorDepth = DefaultMaxErrorDepth + } + if c.MaxBreadcrumbs == 0 { + c.MaxBreadcrumbs = DefaultMaxBreadcrumbs + } + if c.QueueSize <= 0 { + c.QueueSize = DefaultQueueSize + } + if c.Timeout <= 0 { + c.Timeout = DefaultTimeout + } + return c +} + +// validate checks that the endpoint is usable. Called with a normalized config. +func (c Config) validate() error { + if c.Endpoint == "" { + return errors.New("bt: Config.Endpoint is required (or set BACKTRACE_ENDPOINT)") + } + u, err := url.Parse(c.Endpoint) + if err != nil { + return fmt.Errorf("bt: invalid Config.Endpoint: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("bt: Config.Endpoint must be an http(s) URL, got %q", c.Endpoint) + } + return nil +} + +// submissionURL builds the URL reports are POSTed to. +// +// With a Token, the legacy form {endpoint}/post?format=json&token={token} is +// used; without one the endpoint is assumed to be a complete submission URL +// (e.g. https://submit.backtrace.io/{universe}/{token}/json). A +// submit.backtrace.io endpoint already embeds its token in the path and is +// always used verbatim, so a stray Token (e.g. BACKTRACE_TOKEN in the +// environment) cannot corrupt it. +func (c Config) submissionURL() string { + if c.Token == "" { + return c.Endpoint + } + if u, err := url.Parse(c.Endpoint); err == nil && + strings.EqualFold(u.Hostname(), "submit.backtrace.io") { + return c.Endpoint + } + v := url.Values{} + v.Set("format", "json") + v.Set("token", c.Token) + return fmt.Sprintf("%s/post?%s", c.Endpoint, v.Encode()) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..21cf9eb --- /dev/null +++ b/config_test.go @@ -0,0 +1,140 @@ +package bt + +import ( + "strings" + "testing" + "time" +) + +func TestConfigNormalizeDefaults(t *testing.T) { + cfg := Config{Endpoint: "http://example.com"}.normalize() + + if cfg.SourceCode != SourceCodeContext { + t.Errorf("SourceCode = %q, want context", cfg.SourceCode) + } + if cfg.ContextLineCount != DefaultContextLineCount { + t.Errorf("ContextLineCount = %d", cfg.ContextLineCount) + } + if cfg.TabWidth != DefaultTabWidth { + t.Errorf("TabWidth = %d", cfg.TabWidth) + } + if cfg.SampleRate != 1.0 { + t.Errorf("SampleRate = %v, want 1.0", cfg.SampleRate) + } + if cfg.QueueSize != DefaultQueueSize { + t.Errorf("QueueSize = %d", cfg.QueueSize) + } + if cfg.Timeout != DefaultTimeout { + t.Errorf("Timeout = %v", cfg.Timeout) + } + if cfg.MaxErrorDepth != DefaultMaxErrorDepth { + t.Errorf("MaxErrorDepth = %d", cfg.MaxErrorDepth) + } + if cfg.MaxBreadcrumbs != DefaultMaxBreadcrumbs { + t.Errorf("MaxBreadcrumbs = %d", cfg.MaxBreadcrumbs) + } +} + +func TestConfigNormalizePreservesExplicit(t *testing.T) { + cfg := Config{ + Endpoint: "http://example.com", + ContextLineCount: 3, + SampleRate: 0.5, + QueueSize: 7, + Timeout: time.Second, + MaxErrorDepth: -1, + MaxBreadcrumbs: -1, + }.normalize() + + if cfg.ContextLineCount != 3 || cfg.SampleRate != 0.5 || cfg.QueueSize != 7 || + cfg.Timeout != time.Second { + t.Errorf("explicit values overridden: %+v", cfg) + } + if cfg.MaxErrorDepth != -1 { + t.Errorf("MaxErrorDepth -1 (disabled) not preserved: %d", cfg.MaxErrorDepth) + } + if cfg.MaxBreadcrumbs != -1 { + t.Errorf("MaxBreadcrumbs -1 (disabled) not preserved: %d", cfg.MaxBreadcrumbs) + } +} + +func TestConfigEnvFallback(t *testing.T) { + t.Setenv(envEndpoint, "http://env.example.com") + t.Setenv(envToken, "env-token") + + cfg := Config{}.normalize() + if cfg.Endpoint != "http://env.example.com" || cfg.Token != "env-token" { + t.Errorf("env fallback not applied: %+v", cfg) + } + if err := cfg.validate(); err != nil { + t.Errorf("validate after env fallback: %v", err) + } +} + +func TestSubmissionURLForms(t *testing.T) { + // Neutralize any BACKTRACE_* variables from the outer environment. + t.Setenv(envEndpoint, "") + t.Setenv(envToken, "") + + // Full submit URL: used verbatim. + cfg := Config{Endpoint: "https://submit.backtrace.io/universe/tok123/json"}.normalize() + if got := cfg.submissionURL(); got != "https://submit.backtrace.io/universe/tok123/json" { + t.Errorf("full URL form = %q", got) + } + + // A stray token (e.g. BACKTRACE_TOKEN in the environment) must not + // corrupt a complete submit.backtrace.io URL. + cfg = Config{Endpoint: "https://submit.backtrace.io/universe/tok123/json", Token: "stray"}.normalize() + if got := cfg.submissionURL(); got != "https://submit.backtrace.io/universe/tok123/json" { + t.Errorf("submit URL corrupted by stray token: %q", got) + } + + // Legacy endpoint + token: token is query-escaped. + cfg = Config{Endpoint: "https://uni.sp.backtrace.io", Token: "a&b #c"}.normalize() + got := cfg.submissionURL() + if !strings.HasPrefix(got, "https://uni.sp.backtrace.io/post?") { + t.Errorf("legacy form = %q", got) + } + if strings.Contains(got, "a&b") || !strings.Contains(got, "format=json") { + t.Errorf("token not escaped or format missing: %q", got) + } +} + +func TestConfigValidate(t *testing.T) { + if err := (Config{}).validate(); err == nil { + t.Error("empty endpoint accepted") + } + if err := (Config{Endpoint: "not a url\x7f"}).validate(); err == nil { + t.Error("unparsable endpoint accepted") + } + if err := (Config{Endpoint: "ftp://x"}).validate(); err == nil { + t.Error("non-http scheme accepted") + } + if err := (Config{Endpoint: "https://submit.backtrace.io/u/t/json"}).validate(); err != nil { + t.Errorf("valid endpoint rejected: %v", err) + } +} + +func TestUnwrapErrorChainDepthCap(t *testing.T) { + err := error(&testWrapErr{msg: "0"}) + for i := 1; i < 10; i++ { + err = &testWrapErr{msg: string(rune('0' + i)), inner: err} + } + if got := len(unwrapErrorChain(err, 3)); got != 3 { + t.Errorf("depth-capped chain = %d, want 3", got) + } + if got := unwrapErrorChain(err, -1); got != nil { + t.Errorf("negative depth should disable capture, got %d", len(got)) + } + if got := unwrapErrorChain(nil, 10); got != nil { + t.Error("nil error should produce no chain") + } +} + +type testWrapErr struct { + msg string + inner error +} + +func (e *testWrapErr) Error() string { return e.msg } +func (e *testWrapErr) Unwrap() error { return e.inner } diff --git a/examples/bcd/main.go b/examples/bcd/main.go new file mode 100644 index 0000000..6e9c2e9 --- /dev/null +++ b/examples/bcd/main.go @@ -0,0 +1,134 @@ +//go:build linux || freebsd + +// Command bcd demonstrates the out-of-process tracer integration: signal +// handling, panic recovery, and manual trace requests with snapshot upload. +package main + +import ( + "errors" + "fmt" + "os" + "sync" + "syscall" + "time" + + bt "github.com/backtrace-labs/backtrace-go" +) + +var ( + tracer *bt.BTTracer + wg sync.WaitGroup +) + +// panicAndRecover triggers a panic that is traced and then recovered. +func panicAndRecover() { + defer bt.Recover(tracer, false, &bt.TraceOptions{ + Faulted: true, + CallerOnly: true, + ErrClassification: true, + SpawnedGs: &wg, + }) + + panic("panic error") +} + +// raiseSignal sends SIGSEGV to this process; the registered tracer handles it. +func raiseSignal() { + p, err := os.FindProcess(os.Getpid()) + if err != nil { + fmt.Printf("error: failed to find process object: %v\n", err) + return + } + + if err := p.Signal(syscall.SIGSEGV); err != nil { + fmt.Printf("error: failed to send signal: %v\n", err) + } +} + +// requestTrace asks for a snapshot explicitly, without any fault. +func requestTrace() { + wg.Add(1) + go func() { + defer wg.Done() + + fmt.Println("Requesting trace...") + + err := errors.New("trace-request") + if traceErr := bt.Trace(tracer, err, &bt.TraceOptions{ + // Faulted and CallerOnly don't make sense for + // asynchronous trace requests. + Faulted: false, + CallerOnly: false, + ErrClassification: true, + Classifications: []string{"example", "manual-trace"}, + }); traceErr != nil { + fmt.Printf("Failed to trace: %v\n", traceErr) + } + }() +} + +func main() { + // On kernels with restrictive ptrace_scope settings this call allows + // a non-parent tracer to attach to this process. + if err := bt.EnableTracing(); err != nil { + fmt.Printf("Warning: failed to enable tracing permission: %v\n", err) + } + + bt.UpdateConfig(bt.GlobalConfig{ + PanicOnKillFailure: true, + ResendSignal: true, + RateLimit: time.Second * 5, + SynchronousPut: false, + }) + + tracer = bt.New(bt.NewOptions{IncludeSystemGs: false}) + tracer.AddOptions(nil, "-L", "WARNING") + tracer.AddKV(nil, "version", "1.2.3") + tracer.SetLogLevel(bt.LogMax) + + if err := tracer.SetOutputPath("./tracedir", 0755); err != nil { + fmt.Printf("Warning: failed to set output path: %v\n"+ + "Generated snapshots will be stored in cwd.\n", err) + } + + // Tracer I/O is directed to os.DevNull by default. + logFile, err := os.Create("./tracelog") + if err != nil { + fmt.Printf("Warning: failed to create trace log: %v\n", err) + } else { + defer logFile.Close() + tracer.SetPipes(nil, logFile) + } + + if err := tracer.ConfigurePut( + "https://yourcompany.sp.backtrace.io:6098", + "project-token", + bt.PutOptions{Unlink: true, OnTrace: true}, + ); err != nil { + fmt.Printf("Failed to enable put: %v\n", err) + } + + // Upload any snapshots left over from previous runs. + wg.Add(1) + go func() { + defer wg.Done() + if err := tracer.PutDir("./tracedir"); err != nil { + fmt.Printf("Failed to Put from directory: %v\n", err) + } + }() + + // Handle crash signals with the tracer's default signal set. + bt.Register(tracer) + + fmt.Println("Sending signal...") + raiseSignal() + fmt.Println("Signal handled") + + fmt.Println("Panicking...") + panicAndRecover() + fmt.Println("Panic recovered") + + requestTrace() + + wg.Wait() +} diff --git a/examples/main.go b/examples/main.go deleted file mode 100644 index a42a69d..0000000 --- a/examples/main.go +++ /dev/null @@ -1,283 +0,0 @@ -// +build linux freebsd - -package main - -import ( - "github.com/backtrace-labs/backtrace-go" - - "errors" - "fmt" - "os" - "strconv" - "sync" - "syscall" - "time" -) - -const ( - max_recurse = 2 -) - -var ( - tracer *bt.BTTracer - wg sync.WaitGroup -) - -func pan() { - defer bt.Recover(tracer, false, &bt.TraceOptions{ - Faulted: true, - CallerOnly: true, - ErrClassification: true, - SpawnedGs: &wg}) - - panic("panic error") -} - -func sig() { - p, err := os.FindProcess(os.Getpid()) - if err != nil { - fmt.Println("error: failed to find process object") - return - } - - p.Signal(syscall.SIGSEGV) -} - -func recurse(depth int, s1 fishface) { - if depth == 0 { - fmt.Println("Sending signal...") - sig() - fmt.Println("Signal recovered successfully") - - fmt.Println("Panicking...") - pan() - fmt.Println("Panic recovered successfully") - - return - } - - a := 10 - b := "foo" - var h string - i := "" - - f := make(chan string, 3) - f <- "this" - f <- "is" - f <- "Go" - b = <-f - - c := []int{3, 4, 5} - var d [3]int - g := [3]int{7, 8, 9} - m := [300]string{"test"} - - k := &sarlmons{a: 3, b: 4, c: 5, d: "fish"} - - j := map[string]int{} - for z := 0; z < 300; z++ { - j[strconv.Itoa(z)] = z - } - e := map[string]int{"a": 10, "b": 5} - l := map[sarlmons]string{ - sarlmons{3, 4, 5, "fish"}: "what", - sarlmons{4, 5, 6, "fush"}: "the", - sarlmons{5, 6, 7, "fisheded"}: "chicken", - } - - _, _, _, _, _, _, _, _, _, _, _, _ = a, b, c, d, e, f, g, h, i, k, l, m - - wg.Add(1) - go func() { - defer wg.Done() - - fmt.Println("Requesting trace...") - - err := errors.New("trace-request") - - // Request a trace. TraceOptions are optional -- see pan() - // for an example of use with the default options. - traceErr := bt.Trace(tracer, err, &bt.TraceOptions{ - // Note: no (unlimited) timeout. - // Faulted and CallerOnly options don't make sense - // for asynchronous trace requests. See below for a - // synchronous request. - Faulted: false, - CallerOnly: false, - ErrClassification: true, - Classifications: []string{ - "these", "are", "test", "classifiers"}}) - if traceErr != nil { - fmt.Println("Failed to trace: %v", traceErr) - } - - fmt.Println("Done") - }() - - wg.Add(1) - go func() { - defer wg.Done() - - f, err := os.Create("/tmp/dat1") - if err != nil { - panic(err) - } - defer f.Close() - - x := 0 - y := map[sarlmons]string{sarlmons{4, 5, 6, "what"}: "stuff"} - - for { - x += 1 - f.WriteString(fmt.Sprintf("%d", x)) - f.WriteString(y[sarlmons{4, 5, 6, "what"}]) - f.Sync() - - if x >= 1000 { - rf, err := os.OpenFile( - "/home/someone/rdonlyfile", - os.O_RDWR, 0644) - - if err != nil { - fmt.Println("Requesting trace") - bt.Trace(tracer, err, &bt.TraceOptions{ - Faulted: true, - CallerOnly: true, - Timeout: time.Second * 30, - ErrClassification: true}) - break - } - - fmt.Println("Writing to rf") - rf.WriteString("File opened\n") - rf.Sync() - rf.Close() - } - } - - fmt.Println("Done") - }() - - recurse(depth-1, s1) -} - -func start() { - x := &sarlmons{a: 3, b: 4, c: 5, d: "fish"} - x.b += 3 - - recurse(max_recurse, x) -} - -func main() { - // On kernels with specific security settings enabled, this call - // allows a non-parent tracer to run against this process. - // It is not necessary to call this in the absence of these security - // settings. - if err := bt.EnableTracing(); err != nil { - fmt.Printf("Warning: failed to enable tracing permission: %v\n", - err) - } - - bt.UpdateConfig(bt.GlobalConfig{ - PanicOnKillFailure: true, - ResendSignal: true, - RateLimit: time.Second * 5, - SynchronousPut: false}) - - // Use the default tracer implementation. - tracer = bt.New(bt.NewOptions{IncludeSystemGs: false}) - - // Enable WARNING log output from the tracer. - tracer.AddOptions(nil, "-L", "WARNING") - - if err := tracer.SetOutputPath("./tracedir", 0755); err != nil { - fmt.Printf("Warning: failed to set output path: %v.\n" + - "Generated snapshots will be stored in cwd.\n", err) - } - - tracer.AddKV(nil, "version", "1.2.3") - - // Tracer I/O is directed to os.DevNull by default. - // Note: this does not affect the generated output file (unless the - // tracer can only print to stderr). - f, err := os.Create("./tracelog") - if err != nil { - panic(err) - } - defer f.Close() - tracer.SetPipes(nil, f) - - tracer.SetLogLevel(bt.LogMax) - - if err := tracer.ConfigurePut("https://fakeserver.fakecompany.com:6098", - "fakeprojecttoken", - bt.PutOptions{Unlink: true, OnTrace: true}); err != nil { - fmt.Printf("Failed to enable put: %v\n", err) - } - - wg.Add(1) - go func() { - defer wg.Done() - - // Uploads all snapshots contained in the specified directory. - // - // Generally, one should use either BTTracer.PutDir or set the - // OnTrace option to true when calling BTTracer.ConfigurePut. - if err := tracer.PutDir("./tracedir"); err != nil { - fmt.Printf("Failed to Put from directory: %v\n", err) - } - }() - - // Register for signal handling using the tracer's default signal set. - bt.Register(tracer) - - start() - - wg.Wait() -} - -type sarlmons struct { - a, b, c int - d string -} - -type fish map[sarlmons]string - -type moop struct { - a, b, c int - d string - e, f map[string]int -} - -type fntest struct { - a, b int - f func(a, b int) int -} - -func intfn(a, b int) int { - return a + b -} - -type fishface interface { - Plip(i, y int) (result int, err error) - Plop(i []int) (result int, err error) - Dunk(i, y string) (result string, err error) -} - -func (p *sarlmons) Plip(i, y int) (result int, err error) { - return i + y, nil -} - -func (p *sarlmons) Plop(i []int) (result int, err error) { - r := 0 - - for _, z := range i { - r += z - } - - return r, nil -} - -func (p *sarlmons) Dunk(i, y string) (result string, err error) { - return i + y, nil -} diff --git a/examples/report/main.go b/examples/report/main.go new file mode 100644 index 0000000..017d8a8 --- /dev/null +++ b/examples/report/main.go @@ -0,0 +1,64 @@ +// Command report demonstrates the error reporting API: the modern Client, +// panic capture, breadcrumbs, runtime attributes, and the net/http +// middleware. +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "os" + "time" + + bt "github.com/backtrace-labs/backtrace-go" + "github.com/backtrace-labs/backtrace-go/bthttp" +) + +func main() { + client, err := bt.NewClient(bt.Config{ + // Or set BACKTRACE_ENDPOINT / BACKTRACE_TOKEN in the environment. + Endpoint: os.Getenv("BACKTRACE_ENDPOINT"), // e.g. https://submit.backtrace.io/{universe}/{token}/json + Attributes: map[string]interface{}{ + "application.environment": "development", + }, + // Scrub or drop reports before submission: + BeforeSend: func(r *bt.ReportData) *bt.ReportData { + delete(r.Attributes, "internal.hostname.alias") + return r + }, + Debug: true, + }) + if err != nil { + log.Fatalf("backtrace: %v", err) + } + defer client.Close() + + // Attributes and breadcrumbs may be added at any time, from any goroutine. + client.SetAttribute("app.version", "1.2.3") + client.AddBreadcrumb(bt.Breadcrumb{Message: "service starting", Level: bt.BreadcrumbInfo}) + + // Report an error with per-report attributes. + if _, err := os.Open("/does/not/exist"); err != nil { + client.Report(fmt.Errorf("startup check failed: %w", err), map[string]interface{}{ + "check": "filesystem", + }) + } + + // Capture panics in HTTP handlers with request attributes attached. + handler := bthttp.New(bthttp.Options{ + Client: client, + Repanic: false, + WaitForDelivery: true, + }) + mux := http.NewServeMux() + mux.HandleFunc("/boom", func(w http.ResponseWriter, r *http.Request) { + panic(errors.New("handler exploded")) + }) + _ = handler.Handle(mux) // pass to http.ListenAndServe in a real service + + // Wait for queued reports before exiting. + if !client.Flush(5 * time.Second) { + log.Print("backtrace: flush timed out; some reports may be dropped") + } +} diff --git a/go.mod b/go.mod index 0d803b5..2c7fdbf 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,5 @@ module github.com/backtrace-labs/backtrace-go -go 1.22 +go 1.25.0 -require ( - github.com/google/uuid v1.6.0 - github.com/stretchr/testify v1.9.0 - golang.org/x/sys v0.25.0 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) +require golang.org/x/sys v0.46.0 diff --git a/go.sum b/go.sum index fccc22f..5975a78 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,2 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..ae0487d --- /dev/null +++ b/logger.go @@ -0,0 +1,34 @@ +package bt + +import ( + "log" + "os" +) + +// Logger is the minimal logging interface used for SDK diagnostics. +// The standard library *log.Logger satisfies it. +type Logger interface { + Printf(format string, v ...interface{}) +} + +// defaultDiagLogger writes SDK diagnostics to stderr with a stable prefix. +var defaultDiagLogger Logger = log.New(os.Stderr, "[backtrace] ", log.LstdFlags) + +// diag is an internal logging helper. Diagnostics are emitted only when +// debug mode is enabled; the SDK never panics and never writes to +// stdout/stderr unless debugging was requested. +type diag struct { + logger Logger + debug bool +} + +func (d diag) logf(format string, v ...interface{}) { + if !d.debug { + return + } + l := d.logger + if l == nil { + l = defaultDiagLogger + } + l.Printf(format, v...) +} diff --git a/main.go b/main.go index b114c4c..56d2744 100644 --- a/main.go +++ b/main.go @@ -1,413 +1,266 @@ +// Package bt is the Backtrace error reporting SDK for Go, plus an +// integration with out-of-process tracers (see bcd.go). +// +// # Modern API +// +// Create a Client with NewClient and report errors, messages, and recovered +// panics through it: +// +// client, err := bt.NewClient(bt.Config{ +// Endpoint: "https://submit.backtrace.io/{universe}/{token}/json", +// }) +// if err != nil { ... } +// defer client.Close() +// client.Report(err, nil) +// +// # Legacy global API +// +// The package-level functions (Report, ReportPanic, ReportAndRecoverPanic, +// FinishSendingReports) operate on a default client configured through the +// global Options variable and remain fully supported: +// +// bt.Options.Endpoint = "https://submit.backtrace.io/{universe}/{token}/json" +// bt.Report(err, nil) +// +// Configure Options before the first report. For runtime attribute changes +// use SetAttribute (safe for concurrent use) instead of mutating +// Options.Attributes. package bt import ( - "bytes" - cryptorand "crypto/rand" - "encoding/json" - "fmt" - "io" - "log" - mathrand "math/rand" "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" + "sync" "time" - - "github.com/google/uuid" -) - -const ( - VersionMajor = 1 - VersionMinor = 0 - VersionPatch = 0 -) - -var ( - Version = fmt.Sprintf("%d.%d.%d", VersionMajor, VersionMinor, VersionPatch) - - windowsGUIDCommand = []string{"reg", "query", "\"HKEY_LOCAL_MACHINE\\Software\\Microsoft\\Cryptography\"", "/v", "MachineGuid"} - linuxGUIDCommand = []string{"sh", "-c", "( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :"} - freebsdGUIDCommand = []string{"sh", "-c", "kenv -q smbios.system.uuid || sysctl -n kern.hostuuid"} - darwinGUIDCommand = []string{"sh", "-c", "ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID | awk -F'= \"' '{print $2}' | tr -d '\"' | tr -d '\n'"} - - windowsCPUCommand = []string{"wmic", "CPU", "get", "NAME"} - linuxCPUCommand = []string{"sh", "-c", "lscpu | grep \"Model name\" | awk -F':' '{print $2}' | sed 's/^[[:space:]]*//'"} - darwinCPUCommand = []string{"sh", "-c", "sysctl -n machdep.cpu.brand_string | tr -d '\n'"} - freebsdCPUCommand = []string{"sh", "-c", "sysctl -n hw.model"} - - linuxOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} - darwinOSVersionCommand = []string{"sh", "-c", "sw_vers | grep ProductVersion | awk -F':' '{print $2}' | tr -d '\t' | tr -d '\n'"} - freebsdOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} ) +// OptionsStruct configures the legacy global reporter. New code should +// prefer NewClient with a Config. type OptionsStruct struct { + // Endpoint is the Backtrace submission URL; see Config.Endpoint. Endpoint string - Token string - // SendEnvVars gathers and sends all environment variables with every report if true. Default false. + // Token is the project token for legacy endpoints; see Config.Token. + Token string + + // SendEnvVars attaches the process environment (secret-looking values + // redacted) to every report. Default false. SendEnvVars bool + // CaptureAllGoroutines includes every goroutine's stack in reports. CaptureAllGoroutines bool - TabWidth int - ContextLineCount int - Attributes map[string]interface{} - DebugBacktrace bool + // TabWidth is reported to the Backtrace UI for source rendering. + TabWidth int + // ContextLineCount limits the source lines captured around each frame. + ContextLineCount int + // Attributes are added to every report. Prefer SetAttribute for + // changes made while the application is running. + Attributes map[string]interface{} + // DebugBacktrace enables SDK diagnostic logging. Unlike historical + // versions, the SDK never panics: failures are logged instead. + DebugBacktrace bool + + // SourceCode controls source embedding; see Config.SourceCode. + // Default: SourceCodeContext (historical behavior was whole files; + // opt back in with SourceCodeFile). + SourceCode SourceCodeMode + // SampleRate is the fraction of reports sent; zero means 1.0. + SampleRate float64 + // BeforeSend runs before each report is serialized; see Config.BeforeSend. + BeforeSend func(report *ReportData) *ReportData + // ScrubEnvVars extends the built-in redaction patterns; see Config.ScrubEnvVars. + ScrubEnvVars []string + // Logger receives diagnostics when DebugBacktrace is on. + Logger Logger + // HTTPClient overrides the submission HTTP client. + HTTPClient *http.Client + // Timeout is the per-request HTTP timeout (default 30s). Applied when + // the default client is created (first report). + Timeout time.Duration + // QueueSize is the report queue capacity (default 128). Applied when + // the default client is created (first report). + QueueSize int + // MaxErrorDepth caps error-chain unwrapping; see Config.MaxErrorDepth. + MaxErrorDepth int + // MaxBreadcrumbs caps the breadcrumb buffer; applied at first report. + MaxBreadcrumbs int + // AttachmentPaths lists files attached to every report. + AttachmentPaths []string + // DisableMachineAttributes skips exec-based machine metadata collection. + DisableMachineAttributes bool } +// Options configures the legacy global reporter. Set fields before the +// first report; concurrent mutation while reporting is not synchronized +// (use SetAttribute / SetAttributes for runtime attribute updates). var Options OptionsStruct -var rng *mathrand.Rand - -type reportPayload struct { - stack []byte - attributes map[string]interface{} - annotations map[string]interface{} - timestamp int64 - classifier string -} - -var queue = make(chan interface{}, 50) -var doneChan = make(chan struct{}) -var blockChan = make(chan struct{}) - func init() { - var err error - - var seedBytes [8]byte - _, err = cryptorand.Read(seedBytes[:]) - if err != nil { - panic(err) - } - - seed := - (int64(seedBytes[0]) << 0) | - (int64(seedBytes[1]) << 1) | - (int64(seedBytes[2]) << 2) | - (int64(seedBytes[3]) << 3) | - (int64(seedBytes[4]) << 4) | - (int64(seedBytes[5]) << 5) | - (int64(seedBytes[6]) << 6) | - (int64(seedBytes[7]) << 7) - - randSource := mathrand.NewSource(seed) - rng = mathrand.New(randSource) - - setDefaultAttributes() - - go sendWorkerMain() + // Historical behavior: Options.Attributes is usable at import time, + // so existing `bt.Options.Attributes[k] = v` call sites keep working. + Options.Attributes = map[string]interface{}{} } -func setDefaultAttributes() { - if Options.Attributes == nil { - Options.Attributes = make(map[string]interface{}) - } - - hostName, _ := os.Hostname() - Options.Attributes["backtrace.version"] = Version - Options.Attributes["backtrace.agent"] = "backtrace-go" - Options.Attributes["hostname"] = hostName - Options.Attributes["uname.sysname"] = runtime.GOOS - Options.Attributes["cpu.arch"] = runtime.GOARCH - Options.Attributes["process.id"] = os.Getpid() - Options.Attributes["application.session"] = uuid.New() - Options.Attributes["application"] = filepath.Base(os.Args[0]) - - guiCommand := []string{} - cpuCommand := []string{} - osCommand := []string{} - switch runtime.GOOS { - case "windows": - guiCommand = windowsGUIDCommand - cpuCommand = windowsCPUCommand - case "linux": - guiCommand = linuxGUIDCommand - cpuCommand = linuxCPUCommand - osCommand = linuxOSVersionCommand - case "darwin": - guiCommand = darwinGUIDCommand - cpuCommand = darwinCPUCommand - osCommand = darwinOSVersionCommand - case "freebsd": - guiCommand = freebsdGUIDCommand - cpuCommand = freebsdCPUCommand - osCommand = freebsdOSVersionCommand - } - - if len(guiCommand) > 0 { - if output := execCommand(guiCommand); output != "" { - if runtime.GOOS == "windows" { - // windows gives: - // HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography - // MachineGuid REG_SZ {XXXX-XXXX-XXXX-XXXX-XXXX} - if splitOutput := strings.Split(output, "{"); len(splitOutput) > 1 { - output = strings.TrimSuffix(splitOutput[1], "}") - } - } +// legacyAttrMu guards Options.Attributes for callers using SetAttribute +// alongside the legacy global API. +var legacyAttrMu sync.Mutex - Options.Attributes["guid"] = output - } - } - - if len(cpuCommand) > 0 { - if output := execCommand(cpuCommand); output != "" { - if runtime.GOOS == "windows" { - // windows gives: - //NAME - //Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz - if splitOutput := strings.Split(output, "\n"); len(splitOutput) > 1 { - output = splitOutput[1] - } - } - - Options.Attributes["cpu.brand"] = output - } - } - - if len(osCommand) > 0 { - if output := execCommand(osCommand); output != "" { - Options.Attributes["uname.version"] = output - } +// optionsToConfig snapshots the global Options into a Config. +func optionsToConfig() Config { + legacyAttrMu.Lock() + attrs := make(map[string]interface{}, len(Options.Attributes)) + for k, v := range Options.Attributes { + attrs[k] = v } + legacyAttrMu.Unlock() + + return Config{ + Endpoint: Options.Endpoint, + Token: Options.Token, + SendEnvVars: Options.SendEnvVars, + CaptureAllGoroutines: Options.CaptureAllGoroutines, + TabWidth: Options.TabWidth, + ContextLineCount: Options.ContextLineCount, + Attributes: attrs, + Debug: Options.DebugBacktrace, + SourceCode: Options.SourceCode, + SampleRate: Options.SampleRate, + BeforeSend: Options.BeforeSend, + ScrubEnvVars: Options.ScrubEnvVars, + Logger: Options.Logger, + HTTPClient: Options.HTTPClient, + Timeout: Options.Timeout, + QueueSize: Options.QueueSize, + MaxErrorDepth: Options.MaxErrorDepth, + MaxBreadcrumbs: Options.MaxBreadcrumbs, + AttachmentPaths: Options.AttachmentPaths, + DisableMachineAttributes: Options.DisableMachineAttributes, + }.normalize() } -// first value in array is command to exec, rest are arguments. -// e.g. []string{"sh", "-c", "sysctl -n foo_bar | grep foo_bar | tr -d "foo_var" "} -func execCommand(commands []string) string { - out, err := exec.Command(commands[0], commands[1:]...).Output() - if err != nil { - if Options.DebugBacktrace { - log.Println(err) - } - } - - return string(out) -} +var ( + defaultClientMu sync.Mutex + defaultClientV *Client +) -func Report(object interface{}, extraAttributes map[string]interface{}) { - if extraAttributes == nil { - extraAttributes = map[string]interface{}{} - } - if extraAttributes["report_type"] == nil { - extraAttributes["report_type"] = "error" +// defaultClient lazily creates the client backing the legacy global API. +// Returns nil while Options.Endpoint (and BACKTRACE_ENDPOINT) are unset: +// the global API is then a safe no-op. +func defaultClient() *Client { + defaultClientMu.Lock() + defer defaultClientMu.Unlock() + if defaultClientV != nil { + return defaultClientV } - switch value := object.(type) { - case nil: - return - case error: - sendReportString(value.Error(), "error", extraAttributes) - default: - sendReportString(fmt.Sprint(value), "message", extraAttributes) + cfg := optionsToConfig() + if err := cfg.validate(); err != nil { + diag{logger: Options.Logger, debug: Options.DebugBacktrace}.logf("reporting disabled: %v", err) + return nil } + // The legacy client re-reads Options on every report so historical + // patterns (mutating bt.Options at runtime) keep working; queue size, + // timeout, and HTTP client are fixed at creation. + defaultClientV = startClient(optionsToConfig) + return defaultClientV } -func sendReportString(msg string, classifier string, extraAttributes map[string]interface{}) { - if !checkOptions() { - return - } - - timestamp := time.Now().Unix() - - attributes := map[string]interface{}{} - - updateAttrsWithProcMemInfo(attributes) - - for k, v := range Options.Attributes { - attributes[k] = v - } - - attributes["error.message"] = msg - - for k, v := range extraAttributes { - attributes[k] = v - } - - annotations := map[string]interface{}{} - if Options.SendEnvVars { - annotations["Environment Variables"] = getEnvVars() - } - - payload := &reportPayload{ - stack: stack(Options.CaptureAllGoroutines), - attributes: attributes, - annotations: annotations, - timestamp: timestamp, - classifier: classifier, +// Report sends an error report through the legacy global reporter. object +// may be an error or any value convertible to a string; nil is ignored. +// extraAttributes are added to this report only. Safe no-op while the SDK +// is unconfigured. Never blocks on network I/O. +func Report(object interface{}, extraAttributes map[string]interface{}) { + if c := defaultClient(); c != nil { + c.Report(object, extraAttributes) } - queue <- payload } +// ReportPanic reports a panic and re-panics with the original value, after +// waiting up to DefaultFlushTimeout for delivery. Use with defer: +// +// defer bt.ReportPanic(nil) func ReportPanic(extraAttributes map[string]interface{}) { - if !checkOptions() { - return - } - - err := recover() - if err == nil { + v := recover() + if v == nil { return } - - if extraAttributes == nil { - extraAttributes = map[string]interface{}{} + if c := defaultClient(); c != nil { + c.ReportPanicValue(v, extraAttributes) + c.Flush(DefaultFlushTimeout) } - extraAttributes["report_type"] = "panic" - - Report(err, extraAttributes) - finishSendingReports(false) - panic(err) + panic(v) } +// ReportAndRecoverPanic reports a panic and swallows it; the goroutine +// lives on. Use with defer. func ReportAndRecoverPanic(extraAttributes map[string]interface{}) { - if !checkOptions() { + v := recover() + if v == nil { return } - - if extraAttributes == nil { - extraAttributes = map[string]interface{}{} + if c := defaultClient(); c != nil { + c.ReportPanicValue(v, extraAttributes) } - extraAttributes["report_type"] = "panic" - - Report(recover(), extraAttributes) } -func stack(all bool) []byte { - buf := make([]byte, 1024) - for { - n := runtime.Stack(buf, all) - if n < len(buf) { - return buf[:n] - } - buf = make([]byte, 2*len(buf)) +// ReportPanicValue reports an already-recovered panic value through the +// legacy global reporter without re-panicking. Intended for middleware and +// custom recover() handlers. +func ReportPanicValue(value interface{}, extraAttributes map[string]interface{}) { + if c := defaultClient(); c != nil { + c.ReportPanicValue(value, extraAttributes) } } -func getEnvVars() map[string]string { - lines := os.Environ() - result := map[string]string{} - for _, line := range lines { - kv := strings.Split(line, "=") - result[kv[0]] = kv[1] +// SetAttribute sets a global attribute included in every subsequent report. +// Safe for concurrent use; prefer this over mutating Options.Attributes. +func SetAttribute(key string, value interface{}) { + legacyAttrMu.Lock() + if Options.Attributes == nil { + Options.Attributes = map[string]interface{}{} } - return result + Options.Attributes[key] = value + legacyAttrMu.Unlock() } -func checkOptions() bool { - if len(Options.Endpoint) == 0 { - if !Options.DebugBacktrace { - return false - } - panic("must set bt.Options.Endpoint") +// SetAttributes sets multiple global attributes atomically. +func SetAttributes(attrs map[string]interface{}) { + legacyAttrMu.Lock() + if Options.Attributes == nil { + Options.Attributes = map[string]interface{}{} } - - if !strings.HasPrefix(Options.Endpoint, "https://submit.backtrace.io") { - if len(Options.Token) == 0 { - if !Options.DebugBacktrace { - return false - } - panic("must set bt.Options.Token") - } + for k, v := range attrs { + Options.Attributes[k] = v } - return true + legacyAttrMu.Unlock() } -func createUuid() string { - var uuidBytes [16]byte - _, _ = rng.Read(uuidBytes[:]) // This function is documented to never fail. - return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", - uuidBytes[0], uuidBytes[1], uuidBytes[2], uuidBytes[3], - uuidBytes[4], uuidBytes[5], - uuidBytes[6], uuidBytes[7], - uuidBytes[8], uuidBytes[9], - uuidBytes[10], uuidBytes[11], uuidBytes[12], uuidBytes[13], uuidBytes[14], uuidBytes[15]) +// AddBreadcrumb records a breadcrumb on the legacy global reporter. +func AddBreadcrumb(b Breadcrumb) { + if c := defaultClient(); c != nil { + c.AddBreadcrumb(b) + } } -func sendWorkerMain() { - for { - select { - case queueItem := <-queue: - switch value := queueItem.(type) { - case nil: - doneChan <- struct{}{} - return - case *reportPayload: - processAndSend(value) - default: - panic("invalid queue item") - } - case <-blockChan: - doneChan <- struct{}{} - } - +// Flush blocks until reports queued at the time of the call are processed +// or timeout elapses, reporting whether the drain completed. The reporter +// stays fully usable afterwards. +func Flush(timeout time.Duration) bool { + c := currentDefaultClient() + if c == nil { + return true } + return c.Flush(timeout) } +// FinishSendingReports blocks until queued reports are sent (bounded by the +// configured HTTP timeout per report, 30s overall). Unlike historical +// versions it does NOT stop the reporter: reporting continues to work +// afterwards. Kept for backward compatibility — new code should use Flush. func FinishSendingReports() { - finishSendingReports(true) -} -func finishSendingReports(kill bool) { - if kill { - queue <- nil - } else { - blockChan <- struct{}{} - } - <-doneChan + Flush(DefaultTimeout) } -func processAndSend(payload *reportPayload) { - threads, sourceCode := ParseThreadsFromStack(payload.stack) - - report := map[string]interface{}{} - report["uuid"] = createUuid() - report["timestamp"] = payload.timestamp - report["lang"] = "go" - report["langVersion"] = runtime.Version() - report["agent"] = "backtrace-go" - report["agentVersion"] = Version - report["attributes"] = payload.attributes - report["annotations"] = payload.annotations - report["threads"] = threads - report["mainThread"] = "0" - report["sourceCode"] = sourceCode - report["classifiers"] = []string{payload.classifier} - - fullUrl := Options.Endpoint - - if len(Options.Token) != 0 { // if token is set that means its old URL. - fullUrl = fmt.Sprintf("%s/post?format=json&token=%s", Options.Endpoint, url.QueryEscape(Options.Token)) - } - - if Options.DebugBacktrace { - fmt.Fprintf(os.Stderr, "POST %s\n", fullUrl) - var err error - jsonBytes, err := json.MarshalIndent(report, "", " ") - if err != nil { - panic(err) - } - fmt.Fprintf(os.Stderr, "%s\n", string(jsonBytes)) - } - - jsonBytes, err := json.Marshal(report) - if err != nil { - if Options.DebugBacktrace { - panic(err) - } - return - } - resp, err := http.Post(fullUrl, "application/json", bytes.NewReader(jsonBytes)) - if err != nil { - if Options.DebugBacktrace { - panic(err) - } - return - } - defer resp.Body.Close() - - if _, err = io.ReadAll(resp.Body); err != nil { - if Options.DebugBacktrace { - panic(err) - } - return - } +// currentDefaultClient returns the default client without creating one. +func currentDefaultClient() *Client { + defaultClientMu.Lock() + defer defaultClientMu.Unlock() + return defaultClientV } diff --git a/main_test.go b/main_test.go index b56721e..af090c6 100644 --- a/main_test.go +++ b/main_test.go @@ -3,111 +3,293 @@ package bt import ( "encoding/json" "errors" - "fmt" "io" - "net" "net/http" + "net/http/httptest" "os" + "regexp" + "sync" "testing" + "time" ) -func setupServer() { - var err error - addr := net.TCPAddr{ - IP: []byte{127, 0, 0, 1}, +// recordingServer captures submitted reports for assertions. +type recordingServer struct { + mu sync.Mutex + payloads []map[string]interface{} + status int + block chan struct{} // when non-nil, handler blocks until closed + srv *httptest.Server +} + +func newRecordingServer() *recordingServer { + rs := &recordingServer{status: http.StatusOK} + rs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rs.mu.Lock() + block := rs.block + status := rs.status + rs.mu.Unlock() + + if block != nil { + <-block + } + + body, err := io.ReadAll(r.Body) + if err == nil { + payload := map[string]interface{}{} + if json.Unmarshal(body, &payload) == nil { + rs.mu.Lock() + rs.payloads = append(rs.payloads, payload) + rs.mu.Unlock() + } + } + w.WriteHeader(status) + })) + return rs +} + +func (rs *recordingServer) count() int { + rs.mu.Lock() + defer rs.mu.Unlock() + return len(rs.payloads) +} + +func (rs *recordingServer) last() map[string]interface{} { + rs.mu.Lock() + defer rs.mu.Unlock() + if len(rs.payloads) == 0 { + return nil } - listener, err := net.ListenTCP("tcp4", &addr) - if err != nil { - panic(err) + return rs.payloads[len(rs.payloads)-1] +} + +func (rs *recordingServer) reset() { + rs.mu.Lock() + defer rs.mu.Unlock() + rs.payloads = nil +} + +func attrsOf(t *testing.T, payload map[string]interface{}) map[string]interface{} { + t.Helper() + attrs, ok := payload["attributes"].(map[string]interface{}) + if !ok { + t.Fatalf("payload has no attributes object: %v", payload) } - port := listener.Addr().(*net.TCPAddr).Port + return attrs +} + +// legacyServer backs the legacy global API for the whole test binary; the +// default client is process-global, so it is configured exactly once. +var legacyServer *recordingServer - Options.Endpoint = fmt.Sprintf("http://127.0.0.1:%d", port) - Options.Token = "fake token" +func TestMain(m *testing.M) { + legacyServer = newRecordingServer() + Options.Endpoint = legacyServer.srv.URL + Options.Token = "test-token" Options.CaptureAllGoroutines = true - //Options.DebugBacktrace = true - Options.ContextLineCount = 2 + os.Exit(m.Run()) +} + +func TestLegacyReportDelivers(t *testing.T) { + legacyServer.reset() - go func() { - handler := myHandler{ - listener: listener, + Report(errors.New("it broke"), map[string]interface{}{"custom": "value"}) + if !Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + if got := legacyServer.count(); got != 1 { + t.Fatalf("expected 1 report, got %d", got) + } + payload := legacyServer.last() + + if payload["lang"] != "go" { + t.Errorf("lang = %v, want go", payload["lang"]) + } + if payload["agent"] != "backtrace-go" { + t.Errorf("agent = %v, want backtrace-go", payload["agent"]) + } + if payload["agentVersion"] != Version { + t.Errorf("agentVersion = %v, want %s", payload["agentVersion"], Version) + } + + uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + if u, _ := payload["uuid"].(string); !uuidRe.MatchString(u) { + t.Errorf("uuid %q is not RFC 4122 v4", u) + } + + attrs := attrsOf(t, payload) + if attrs["error.message"] != "it broke" { + t.Errorf("error.message = %v", attrs["error.message"]) + } + if attrs["custom"] != "value" { + t.Errorf("custom attribute missing: %v", attrs["custom"]) + } + if attrs["report_type"] != "error" { + t.Errorf("report_type = %v, want error", attrs["report_type"]) + } + for _, key := range []string{ + "backtrace.version", "backtrace.agent", "hostname", "uname.sysname", + "cpu.arch", "process.id", "application", "application.session", + "go.version", "runtime.goroutines", + } { + if _, ok := attrs[key]; !ok { + t.Errorf("default attribute %q missing", key) } - err = http.Serve(listener, handler) - }() + } + + threads, ok := payload["threads"].(map[string]interface{}) + if !ok || len(threads) == 0 { + t.Fatalf("threads missing or empty: %v", payload["threads"]) + } + if payload["mainThread"] != "0" { + t.Errorf("mainThread = %v, want 0", payload["mainThread"]) + } + if _, ok := threads["0"]; !ok { + t.Errorf("faulting thread 0 missing; threads: %d", len(threads)) + } + + classifiers, _ := payload["classifiers"].([]interface{}) + if len(classifiers) == 0 || classifiers[0] != "error" { + t.Errorf("classifiers = %v, want [error ...]", classifiers) + } } -func TestMain(m *testing.M) { - setupServer() - os.Exit(m.Run()) +func TestLegacyReportNilIsNoop(t *testing.T) { + legacyServer.reset() + Report(nil, nil) + Flush(2 * time.Second) + if got := legacyServer.count(); got != 0 { + t.Fatalf("nil report was sent: %d", got) + } } -func TestEverything(t *testing.T) { - causeErrorReport() +func TestLegacyReportDoesNotMutateCallerMap(t *testing.T) { + legacyServer.reset() + extra := map[string]interface{}{"k": "v"} + Report("some message", extra) + Flush(5 * time.Second) + + if _, polluted := extra["report_type"]; polluted { + t.Error("caller's attribute map was mutated") + } + attrs := attrsOf(t, legacyServer.last()) + if attrs["report_type"] != "message" { + t.Errorf("report_type = %v, want message", attrs["report_type"]) + } } -func TestPanic(t *testing.T) { - count := 0 - for i := 0; i < 5; i++ { +// TestReportPanicIsDeterministic verifies the marker-based flush: the report +// must be delivered before ReportPanic re-panics, every time (the historical +// implementation lost ~50% of panic reports to a select race). +func TestReportPanicIsDeterministic(t *testing.T) { + legacyServer.reset() + + const iterations = 25 + for i := 0; i < iterations; i++ { func() { defer func() { - _ = recover() - count++ - }() - func() { - defer ReportPanic(nil) - // fire off a panic. this should happen 5 times - panic("it broke") + if recover() == nil { + t.Fatal("ReportPanic did not re-panic") + } }() + defer ReportPanic(nil) + panic("deterministic panic") }() } - if count != 5 { - // really this doesn't do much, since it won't be hit if the code above deadlocks - t.Fatal("Expected 5 panics") + + if got := legacyServer.count(); got != iterations { + t.Fatalf("lost panic reports: got %d, want %d", got, iterations) + } + attrs := attrsOf(t, legacyServer.last()) + if attrs["report_type"] != "panic" { + t.Errorf("report_type = %v, want panic", attrs["report_type"]) } } -type myHandler struct { - listener *net.TCPListener -} +func TestLegacyReportAndRecoverPanic(t *testing.T) { + legacyServer.reset() -func (h myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - defer h.listener.Close() + func() { + defer ReportAndRecoverPanic(nil) + panic("recovered panic") + }() + // Reaching this line proves the panic was swallowed. - var err error - body, err := io.ReadAll(r.Body) - if err != nil { - panic(err) + if !Flush(5 * time.Second) { + t.Fatal("Flush timed out") } - report := map[string]interface{}{} - err = json.Unmarshal(body, &report) - if err != nil { - panic(err) + if got := legacyServer.count(); got != 1 { + t.Fatalf("expected 1 report, got %d", got) } - if report["lang"] != "go" { - panic("bad lang") - } - attributes := report["attributes"].(map[string]interface{}) - if attributes["error.message"] != "it broke" { - panic("bad error message") +} + +// TestFinishSendingReportsKeepsWorkerAlive is the regression test for the +// historical bug where FinishSendingReports killed the worker permanently. +func TestFinishSendingReportsKeepsWorkerAlive(t *testing.T) { + legacyServer.reset() + + Report(errors.New("before finish"), nil) + FinishSendingReports() + FinishSendingReports() // second call must not deadlock + + Report(errors.New("after finish"), nil) + if !Flush(5 * time.Second) { + t.Fatal("Flush timed out after FinishSendingReports") } - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "OK\n") + if got := legacyServer.count(); got != 2 { + t.Fatalf("reports after FinishSendingReports are lost: got %d, want 2", got) + } } -func doSomething(ch chan int) { - <-ch -} +func TestSetAttributeIsConcurrencySafe(t *testing.T) { + legacyServer.reset() -func causeErrorReport() { - go doSomething(make(chan int)) - Report(errors.New("it broke"), nil) - finishSendingReports(false) + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 50; i++ { + SetAttribute("concurrent", g*1000+i) + SetAttributes(map[string]interface{}{"batch": i}) + Report(errors.New("concurrent report"), nil) + } + }(g) + } + wg.Wait() + Flush(10 * time.Second) - for _, v := range []string{"backtrace.version", "backtrace.agent", "hostname", "uname.sysname", "cpu.arch", "process.id", "application.session", "application"} { - if _, ok := Options.Attributes[v]; !ok { - panic(v + " - attribute not set") - } + if legacyServer.count() == 0 { + t.Fatal("no reports delivered") } + attrs := attrsOf(t, legacyServer.last()) + if _, ok := attrs["concurrent"]; !ok { + t.Error("attribute set via SetAttribute missing") + } +} + +func TestLegacyEnvVarAnnotations(t *testing.T) { + legacyServer.reset() + t.Setenv("BT_TEST_SECRET_TOKEN", "hunter2") + t.Setenv("BT_TEST_DATABASE_URL", "postgres://u:p@h/db?sslmode=require") + + Options.SendEnvVars = true + defer func() { Options.SendEnvVars = false }() + Report(errors.New("env test"), nil) + Flush(5 * time.Second) + + annotations, _ := legacyServer.last()["annotations"].(map[string]interface{}) + env, _ := annotations["Environment Variables"].(map[string]interface{}) + if env == nil { + t.Fatal("Environment Variables annotation missing") + } + if env["BT_TEST_SECRET_TOKEN"] != redactedValue { + t.Errorf("secret env var not redacted: %v", env["BT_TEST_SECRET_TOKEN"]) + } + if env["BT_TEST_DATABASE_URL"] != "postgres://u:p@h/db?sslmode=require" { + t.Errorf("env value truncated at '=': %v", env["BT_TEST_DATABASE_URL"]) + } } diff --git a/procmeminfo.go b/procmeminfo.go index 5406ee5..528ac05 100644 --- a/procmeminfo.go +++ b/procmeminfo.go @@ -2,9 +2,7 @@ package bt import ( "bufio" - "fmt" "io" - "log" "os" "runtime" "strconv" @@ -17,8 +15,8 @@ const ( ) var ( - paths = []string{memPath, procPath} - mapper = map[string]string{ + procPaths = []string{memPath, procPath} + procMapper = map[string]string{ "MemTotal": "system.memory.total", "MemFree": "system.memory.free", "MemAvailable": "system.memory.available", @@ -51,74 +49,58 @@ var ( } ) -func updateAttrsWithProcMemInfo(attributes map[string]interface{}) { - if runtime.GOOS == "linux" { - updateAttrsWithProcMemInfoLinux(attributes) +// updateAttrsWithProcMemInfo adds memory and scheduler attributes from +// /proc on Linux; it is a no-op elsewhere. +func updateAttrsWithProcMemInfo(attributes map[string]interface{}, d diag) { + if runtime.GOOS != "linux" { + return } -} - -func updateAttrsWithProcMemInfoLinux(attributes map[string]interface{}) { - for _, path := range paths { - readFileIntoAttrs(path, attributes) + for _, path := range procPaths { + readFileIntoAttrs(path, attributes, d) } } -func readFileIntoAttrs(path string, attributes map[string]interface{}) { +func readFileIntoAttrs(path string, attributes map[string]interface{}, d diag) { file, err := os.Open(path) if err != nil { - if Options.DebugBacktrace { - log.Printf("readFileIntoAttrs err: %v", err) - } + d.logf("readFileIntoAttrs: %v", err) return } defer file.Close() readKeyValueLinesIntoAttrs(file, attributes) } +// readKeyValueLinesIntoAttrs parses "Key: value [kB]" lines, mapping known +// keys to Backtrace attribute names. Values are emitted as numbers where +// possible (kB values converted to bytes) so the Backtrace query engine can +// aggregate them. func readKeyValueLinesIntoAttrs(r io.Reader, attributes map[string]interface{}) { - reader := bufio.NewReader(r) - for { - l, _, err := reader.ReadLine() - if err != nil { - if err == io.EOF { - break - } else { - if Options.DebugBacktrace { - log.Printf("readKeyValueLinesIntoAttrs err: %v", err) - } - break - } + scanner := bufio.NewScanner(r) + for scanner.Scan() { + key, value, ok := strings.Cut(scanner.Text(), ":") + if !ok { + continue } - - values := strings.Split(string(l), ":") - if len(values) == 2 { - attr := values[0] - value, err := getNormalizedValue(values[1]) - if err != nil { - if Options.DebugBacktrace { - log.Printf("readKeyValueLinesIntoAttrs err: %v", err) - } - continue - } - - if btAttr, exists := mapper[attr]; exists { - attributes[btAttr] = value - } + btAttr, known := procMapper[key] + if !known { + continue } + attributes[btAttr] = normalizeProcValue(value) } } -func getNormalizedValue(value string) (string, error) { +// normalizeProcValue converts proc values to int64 where possible; " kB" +// suffixed values become bytes. +func normalizeProcValue(value string) interface{} { value = strings.TrimSpace(value) - if strings.HasSuffix(value, "kB") { - value = strings.TrimSuffix(value, " kB") - - atoi, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return "", err + if kb, found := strings.CutSuffix(value, " kB"); found { + if n, err := strconv.ParseInt(strings.TrimSpace(kb), 10, 64); err == nil { + return n * 1024 } - atoi *= 1024 - return fmt.Sprintf("%d", atoi), err + return value + } + if n, err := strconv.ParseInt(value, 10, 64); err == nil { + return n } - return value, nil + return value } diff --git a/procmeminfo_test.go b/procmeminfo_test.go index 096ab19..b1f6d41 100644 --- a/procmeminfo_test.go +++ b/procmeminfo_test.go @@ -12,9 +12,9 @@ func Test_readKeyValueLinesIntoAttrs(t *testing.T) { attrs := make(map[string]interface{}) readKeyValueLinesIntoAttrs(r, attrs) requireSubmap(t, map[string]interface{}{ - "system.memory.total": "1033457664", - "system.memory.free": "149852160", - "system.memory.dirty": "20480", + "system.memory.total": int64(1033457664), + "system.memory.free": int64(149852160), + "system.memory.dirty": int64(20480), }, attrs) }) @@ -23,12 +23,30 @@ func Test_readKeyValueLinesIntoAttrs(t *testing.T) { attrs := make(map[string]interface{}) readKeyValueLinesIntoAttrs(r, attrs) requireSubmap(t, map[string]interface{}{ - "vm.vma.peak": "9048064", - "descriptor.count": "256", + "vm.vma.peak": int64(9048064), + "descriptor.count": int64(256), }, attrs) }) } +func Test_normalizeProcValue(t *testing.T) { + cases := []struct { + in string + want interface{} + }{ + {" 8836 kB", int64(9048064)}, + {"256", int64(256)}, + {"R (running)", "R (running)"}, + {"0002", int64(2)}, + } + for _, c := range cases { + if got := normalizeProcValue(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("normalizeProcValue(%q) = %v (%T), want %v (%T)", + c.in, got, got, c.want, c.want) + } + } +} + func requireSubmap[K comparable, V any](t *testing.T, submap map[K]V, actual map[K]V) { actualKeys := make([]K, 0, len(actual)) for k := range actual { diff --git a/report.go b/report.go new file mode 100644 index 0000000..b258774 --- /dev/null +++ b/report.go @@ -0,0 +1,104 @@ +package bt + +import ( + "crypto/rand" + "errors" + "fmt" + "runtime" +) + +// ReportData is a fully assembled crash/error report, exposed to the +// Config.BeforeSend hook for scrubbing, enrichment, or dropping. +// +// Mutating maps and slices in place is safe: the hook runs on the SDK worker +// goroutine and the report is serialized immediately afterwards. +type ReportData struct { + // UUID uniquely identifies the report (RFC 4122 version 4). + UUID string + + // Timestamp is the report time in Unix seconds. + Timestamp int64 + + // Classifiers group the report in the Backtrace UI ("error", "panic", + // "message", plus error-chain type names). + Classifiers []string + + // Attributes are indexed key/value pairs used for search and + // aggregation. + Attributes map[string]interface{} + + // Annotations carry larger, non-indexed structured data + // (environment variables, error chains, dependencies, breadcrumbs). + Annotations map[string]interface{} + + // Threads maps thread IDs to captured goroutine stacks. + Threads map[string]Thread + + // SourceCode maps source snippet IDs referenced by stack frames. + SourceCode map[string]SourceCode + + // MainThread is the key in Threads of the faulting goroutine. + MainThread string + + // Attachments lists file paths uploaded with the report as + // "attachment_" multipart parts. Seeded from + // Config.AttachmentPaths; BeforeSend may add or remove entries. + Attachments []string +} + +// toWire converts the report to the Backtrace JSON submission format. +func (r *ReportData) toWire() map[string]interface{} { + return map[string]interface{}{ + "uuid": r.UUID, + "timestamp": r.Timestamp, + "lang": "go", + "langVersion": runtime.Version(), + "agent": "backtrace-go", + "agentVersion": Version, + "classifiers": r.Classifiers, + "attributes": r.Attributes, + "annotations": r.Annotations, + "threads": r.Threads, + "mainThread": r.MainThread, + "sourceCode": r.SourceCode, + } +} + +// uuid4 returns an RFC 4122 version 4 UUID from crypto/rand. +func uuid4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand is documented never to fail on supported + // platforms; if it somehow does, a constant-free fallback is + // still preferable to panicking inside a crash reporter. + for i := range b { + b[i] = byte(i * 17) + } + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// errorChainLink describes one error in an unwrapped chain. +type errorChainLink struct { + Type string `json:"type"` + Message string `json:"message"` +} + +// unwrapErrorChain walks err's Unwrap chain (up to maxDepth links) and +// returns the chain description plus the Go type names encountered, for use +// as classifiers. A maxDepth < 0 disables chain capture. +func unwrapErrorChain(err error, maxDepth int) []errorChainLink { + if err == nil || maxDepth < 0 { + return nil + } + var chain []errorChainLink + for e := err; e != nil && len(chain) < maxDepth; e = errors.Unwrap(e) { + chain = append(chain, errorChainLink{ + Type: fmt.Sprintf("%T", e), + Message: e.Error(), + }) + } + return chain +} diff --git a/threads.go b/threads.go index 5c2387f..e641076 100644 --- a/threads.go +++ b/threads.go @@ -1,25 +1,34 @@ package bt import ( - "fmt" "os" + "strconv" "strings" ) +// sdkFramePrefix identifies the SDK's own frames, which are filtered from +// reported stacks. +const sdkFramePrefix = "github.com/backtrace-labs/backtrace-go" + +// elidedFramesMarker appears in runtime.Stack output when frames are omitted. +const elidedFramesMarker = "...additional frames elided..." + +// Thread is one goroutine's captured stack in the Backtrace wire format. type Thread struct { Name string `json:"name"` Fault bool `json:"fault"` Stacks []StackFrame `json:"stack"` } +// StackFrame is a single frame in the Backtrace wire format. type StackFrame struct { - FuncName string `json:"funcName"` - Library string `json:"library"` - SourceCodeID string `json:"sourceCode"` - Line string `json:"line"` - skipBacktrace bool + FuncName string `json:"funcName"` + Library string `json:"library"` + SourceCodeID string `json:"sourceCode,omitempty"` + Line string `json:"line"` } +// SourceCode is a source snippet referenced by stack frames. type SourceCode struct { Text string `json:"text"` Path string `json:"path"` @@ -29,108 +38,184 @@ type SourceCode struct { TabWidth int `json:"tabWidth"` } -func ParseThreadsFromStack(stackTrace []byte) (map[string]Thread, map[string]SourceCode) { - splitThreads := strings.Split(string(stackTrace), "\n\n") - - sourceCodeID := 0 - - sourcesPath := make(map[string]int) // key: path, value: unique path number starting from 0. - threads := make(map[string]Thread) // key: index of split string, starting from 0. - sourceCodes := make(map[string]SourceCode) // key: unique path number starting from 0. - - for threadID, stackText := range splitThreads { - lines := strings.Split(stackText, "\n") - - sf := StackFrame{} - thread := Thread{Name: strings.TrimSuffix(lines[0], ":"), Fault: threadID == 0} - for i := 1; i < len(lines); i++ { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - - if i%2 != 0 { // odd lines are function paths - line = trimCreatedBy(line) - if strings.HasPrefix(line, "github.com/backtrace-labs/backtrace-go") { - sf.skipBacktrace = true - continue - } - sf.skipBacktrace = false - - lastIndex, function := getLastPathIndexAndFunction(line) +// sourceOptions controls source snippet embedding; see SourceCodeMode. +type sourceOptions struct { + mode SourceCodeMode + contextLines int + tabWidth int +} - if function == "panic" { - sf.FuncName = "panic" - sf.Library = "runtime" - continue - } +// ParseThreadsFromStack parses runtime.Stack output into the Backtrace +// threads and sourceCode payload sections, honoring the global Options for +// source capture (SourceCode mode, ContextLineCount, TabWidth). +func ParseThreadsFromStack(stackTrace []byte) (map[string]Thread, map[string]SourceCode) { + cfg := optionsToConfig() + threads, sourceCodes, _ := buildThreads(stackTrace, sourceOptions{ + mode: cfg.SourceCode, + contextLines: cfg.ContextLineCount, + tabWidth: cfg.TabWidth, + }) + return threads, sourceCodes +} - sf.FuncName = function - sf.Library = line[:lastIndex] - } else { - if sf.skipBacktrace { - continue - } +// buildThreads structurally parses runtime.Stack output. Unlike positional +// (odd/even line) parsing it survives elided-frame markers, "created by" +// lines, frames without locations, and Windows paths. +func buildThreads(stackTrace []byte, opts sourceOptions) (map[string]Thread, map[string]SourceCode, string) { + threads := map[string]Thread{} + sources := newSourceBuilder(opts) - line = strings.TrimSpace(line) - line, _, _ = strings.Cut(line, " +") + var ( + current *Thread + threadID = -1 + pending *StackFrame // function seen, waiting for its location line + skipLoc bool // next location line belongs to a skipped frame + sdkOnly bool // every frame so far was SDK-internal + mainKey string + threadKey string + ) + flushCur := func() { + if current == nil { + return + } + // Goroutines whose only frames were SDK-internal (e.g. the + // SDK's own send worker) are pure noise — drop them. Threads + // that are legitimately frameless ("stack unavailable") stay. + if len(current.Stacks) == 0 && sdkOnly { + return + } + threads[threadKey] = *current + } - path := "" - path, sf.Line, _ = strings.Cut(line, ":") + for _, rawLine := range strings.Split(string(stackTrace), "\n") { + if strings.TrimSpace(rawLine) == "" { + continue + } - if scID, ok := sourcesPath[path]; ok { - sf.SourceCodeID = fmt.Sprintf("%d", scID) - } else { - strSourceCodeID := fmt.Sprintf("%d", sourceCodeID) - sourcesPath[path] = sourceCodeID + indented := rawLine[0] == '\t' || rawLine[0] == ' ' + line := strings.TrimSpace(rawLine) - sourceCodes[strSourceCodeID] = readFileGetSourceCode(path) + switch { + case !indented && strings.HasPrefix(line, "goroutine ") && strings.HasSuffix(line, ":"): + // New goroutine header. + flushCur() + threadID++ + threadKey = strconv.Itoa(threadID) + if threadID == 0 { + mainKey = threadKey + } + current = &Thread{ + Name: strings.TrimSuffix(line, ":"), + Fault: threadID == 0, + Stacks: []StackFrame{}, + } + pending, skipLoc, sdkOnly = nil, false, false - sf.SourceCodeID = strSourceCodeID + case !indented && strings.HasPrefix(line, "[originating from goroutine "): + // GODEBUG=tracebackancestors section: these frames + // describe the creating goroutine's history, not a + // live thread. Ignore until the next goroutine header. + flushCur() + current = nil + pending, skipLoc = nil, false - sourceCodeID++ + case !indented: + // Function line (or a special marker). + pending, skipLoc = nil, false + if current == nil || line == elidedFramesMarker { + continue + } + qualified := trimCreatedBy(line) + if strings.HasPrefix(qualified, sdkFramePrefix) { + if len(current.Stacks) == 0 { + sdkOnly = true } - thread.Stacks = append(thread.Stacks, sf) - sf = StackFrame{} + skipLoc = true + continue } - } + library, function := splitQualifiedFunction(qualified) + if function == "panic" && library == "" { + library = "runtime" + } + pending = &StackFrame{FuncName: function, Library: library} - if len(thread.Stacks) > 0 { - threads[fmt.Sprintf("%d", threadID)] = thread + default: + // Location line ("\t/path/file.go:42 +0x1f"). + if skipLoc { + skipLoc = false + continue + } + if pending == nil || current == nil { + continue + } + path, lineNo := splitLocation(line) + pending.Line = lineNo + pending.SourceCodeID = sources.reference(path, lineNo) + current.Stacks = append(current.Stacks, *pending) + pending = nil } } + flushCur() - return threads, sourceCodes + return threads, sources.result(), mainKey } -func readFileGetSourceCode(path string) SourceCode { - sc := SourceCode{} - bytes, err := os.ReadFile(path) - if err == nil { - sc.Text = string(bytes) - sc.StartLine = 1 - sc.StartColumn = 1 - sc.StartPos = 0 - sc.TabWidth = Options.TabWidth +// splitLocation parses "\t/path/file.go:42 +0x1f" into path and line. The +// split uses the last colon so Windows drive letters ("C:/app/main.go:42") +// survive. +func splitLocation(line string) (path, lineNo string) { + line, _, _ = strings.Cut(line, " +") + line = strings.TrimSpace(line) + idx := strings.LastIndex(line, ":") + if idx < 0 { + return line, "" } - sc.Path = path - - return sc + return line[:idx], line[idx+1:] } -func getLastPathIndexAndFunction(line string) (int, string) { +// splitQualifiedFunction splits a qualified name from a runtime.Stack +// function line into library (package path, possibly with receiver) and +// function name. Handles argument suffixes, method receivers, generic +// instantiations, and names without any dot. +// +// "main.main()" -> "main", "main" +// "testing.(*T).Run(0x14, {...})" -> "testing.(*T)", "Run" +// "pkg.F[go.shape.int](0x1)" -> "pkg", "F[go.shape.int]" +// "panic({0x1?, 0x2?})" -> "", "panic" +func splitQualifiedFunction(line string) (library, function string) { + // Strip the argument list. if strings.HasSuffix(line, ")") { - lastIndex := strings.LastIndex(line, "(") - if lastIndex != -1 { - line = line[:lastIndex] + if open := strings.LastIndex(line, "("); open != -1 { + // Don't strip a method receiver like "pkg.(*T).Run". + if !strings.HasPrefix(line[open:], "(*") || strings.HasSuffix(line, "})") { + line = line[:open] + } } } - lastIndex := strings.LastIndex(line, ".") - function, _, _ := strings.Cut(line[lastIndex+1:], "(") - return lastIndex, function + // Split at the last dot that sits at bracket depth zero: dots inside + // generic type arguments ("pkg.F[go.shape.int]", + // "pkg.(*Cache[go.shape.string]).Get") must never be the split point. + depth, lastDot := 0, -1 + for i, r := range line { + switch r { + case '[': + depth++ + case ']': + depth-- + case '.': + if depth == 0 { + lastDot = i + } + } + } + if lastDot < 0 { + return "", line + } + return line[:lastDot], line[lastDot+1:] } +// trimCreatedBy reduces "created by pkg.fn in goroutine 7" to "pkg.fn". func trimCreatedBy(line string) string { if strings.HasPrefix(line, "created by") { _, line, _ = strings.Cut(line, " by ") @@ -138,3 +223,107 @@ func trimCreatedBy(line string) string { } return line } + +// sourceBuilder deduplicates and extracts source snippets for stack frames. +type sourceBuilder struct { + opts sourceOptions + ids map[string]string // dedup key -> snippet ID + entries map[string]SourceCode + files map[string][]string // per-report file line cache + failed map[string]bool + nextID int +} + +func newSourceBuilder(opts sourceOptions) *sourceBuilder { + return &sourceBuilder{ + opts: opts, + ids: map[string]string{}, + entries: map[string]SourceCode{}, + files: map[string][]string{}, + failed: map[string]bool{}, + } +} + +// reference registers a (path, line) pair and returns the snippet ID a frame +// should carry, or "" when source capture is disabled. +func (b *sourceBuilder) reference(path, lineNo string) string { + if b.opts.mode == SourceCodeNone || path == "" { + return "" + } + + key := path + if b.opts.mode == SourceCodeContext { + key = path + ":" + lineNo + } + if id, ok := b.ids[key]; ok { + return id + } + + id := strconv.Itoa(b.nextID) + b.nextID++ + b.ids[key] = id + b.entries[id] = b.extract(path, lineNo) + return id +} + +func (b *sourceBuilder) result() map[string]SourceCode { + return b.entries +} + +// extract builds the snippet for path around lineNo according to the mode. +// Unreadable files degrade to a path-only entry. +func (b *sourceBuilder) extract(path, lineNo string) SourceCode { + sc := SourceCode{Path: path} + + lines := b.readLines(path) + if lines == nil { + return sc + } + + switch b.opts.mode { + case SourceCodeFile: + sc.Text = strings.Join(lines, "\n") + sc.StartLine = 1 + default: // SourceCodeContext + center, err := strconv.Atoi(lineNo) + if err != nil || center < 1 { + return sc + } + start := center - b.opts.contextLines + if start < 1 { + start = 1 + } + end := center + b.opts.contextLines + if end > len(lines) { + end = len(lines) + } + if start > len(lines) { + return sc + } + sc.Text = strings.Join(lines[start-1:end], "\n") + sc.StartLine = start + } + + sc.StartColumn = 1 + sc.StartPos = 0 + sc.TabWidth = b.opts.tabWidth + return sc +} + +// readLines reads and caches a source file for the duration of one report. +func (b *sourceBuilder) readLines(path string) []string { + if lines, ok := b.files[path]; ok { + return lines + } + if b.failed[path] { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + b.failed[path] = true + return nil + } + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + b.files[path] = lines + return lines +} diff --git a/threads_test.go b/threads_test.go index fec1928..6511789 100644 --- a/threads_test.go +++ b/threads_test.go @@ -1,12 +1,15 @@ package bt import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" "testing" - - "github.com/stretchr/testify/assert" ) -const stackTrace = `goroutine 1 [running]: +const stackFixture = `goroutine 1 [running]: github.com/backtrace-labs/backtrace-go.TestMain(0x1400011a960) /Users/test-user/Documents/Work/backtrace-go/main_test.go:41 +0x28 main.GetStack() @@ -20,12 +23,6 @@ main.testFunc() created by main.main in goroutine 1 /tmp/sandbox889685435/prog.go:13 +0x1e -goroutine 7 [runnable]: -main.testFunc() - /tmp/sandbox889685435/prog.go:22 -created by main.main in goroutine 1 - /tmp/sandbox889685435/prog.go:14 +0x2a - goroutine 8 [running]: main.test.main(0x9) /Users/root/Library/Application Support/JetBrains/GoLand2024.1/scratches/scratch_19.go:74 +0xa0c @@ -39,152 +36,365 @@ created by testing.(*T).Run in goroutine 1 /usr/local/go/src/testing/foobar.go:1742 +0x668 ` -func TestParseThreadsFromStack(t *testing.T) { - type args struct { - stackTrace []byte - } - tests := []struct { - name string - args args - wantThreads map[string]Thread - wantSourceCode map[string]SourceCode - }{ - { - name: "ShouldParseThreadsFromStack", - args: args{ - stackTrace: []byte(stackTrace), +func noSource() sourceOptions { + return sourceOptions{mode: SourceCodeNone, contextLines: 8, tabWidth: 8} +} + +func TestBuildThreadsFixture(t *testing.T) { + threads, sources, mainThread := buildThreads([]byte(stackFixture), noSource()) + + if mainThread != "0" { + t.Errorf("mainThread = %q, want 0", mainThread) + } + if len(sources) != 0 { + t.Errorf("SourceCodeNone produced %d source entries", len(sources)) + } + + want := map[string]Thread{ + "0": { + Name: "goroutine 1 [running]", + Fault: true, + Stacks: []StackFrame{ + {FuncName: "GetStack", Library: "main", Line: "30"}, + {FuncName: "main", Library: "main", Line: "15"}, + }, + }, + "1": { + Name: "goroutine 6 [runnable]", + Stacks: []StackFrame{ + {FuncName: "testFunc", Library: "main", Line: "22"}, + {FuncName: "main", Library: "main", Line: "13"}, }, - wantThreads: map[string]Thread{ - "0": { - Name: "goroutine 1 [running]", - Fault: true, - Stacks: []StackFrame{ - { - FuncName: "GetStack", - Library: "main", - SourceCodeID: "0", - Line: "30", - }, - { - FuncName: "main", - Library: "main", - SourceCodeID: "0", - Line: "15", - }, - }, - }, - "1": { - Name: "goroutine 6 [runnable]", - Stacks: []StackFrame{ - { - FuncName: "testFunc", - Library: "main", - SourceCodeID: "0", - Line: "22", - }, - { - FuncName: "main", - Library: "main", - SourceCodeID: "0", - Line: "13", - }, - }, - }, - "2": { - Name: "goroutine 7 [runnable]", - Stacks: []StackFrame{ - { - FuncName: "testFunc", - Library: "main", - SourceCodeID: "0", - Line: "22", - }, - { - FuncName: "main", - Library: "main", - SourceCodeID: "0", - Line: "14", - }, - }, - }, - "3": { - Name: "goroutine 8 [running]", - Stacks: []StackFrame{ - { - FuncName: "main", - Library: "main.test", - SourceCodeID: "1", - Line: "74", - }, - }, - }, - "4": { - Name: "goroutine 9 [running]", - Stacks: []StackFrame{ - { - FuncName: "Run", - Library: "testing.(*T)", - SourceCodeID: "2", - Line: "12", - }, - { - FuncName: "panic", - Library: "runtime", - SourceCodeID: "3", - Line: "770", - }, - { - FuncName: "Run", - Library: "testing.(*T)", - SourceCodeID: "4", - Line: "1742", - }, - }, - }, + }, + "2": { + Name: "goroutine 8 [running]", + Stacks: []StackFrame{ + {FuncName: "main", Library: "main.test", Line: "74"}, }, - wantSourceCode: map[string]SourceCode{ - "0": { - Path: "/tmp/sandbox889685435/prog.go", - }, - "1": { - Path: "/Users/root/Library/Application Support/JetBrains/GoLand2024.1/scratches/scratch_19.go", - }, - "2": { - Path: "/Users/some_file.go", - }, - "3": { - Text: "", - Path: "/usr/local/go/src/runtime/something.go", - }, - "4": { - Text: "", - Path: "/usr/local/go/src/testing/foobar.go", - }, + }, + "3": { + Name: "goroutine 9 [running]", + Stacks: []StackFrame{ + {FuncName: "Run", Library: "testing.(*T)", Line: "12"}, + {FuncName: "panic", Library: "runtime", Line: "770"}, + {FuncName: "Run", Library: "testing.(*T)", Line: "1742"}, }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotThreads, gotSourceCodes := ParseThreadsFromStack(tt.args.stackTrace) - - waitChan := make(chan int) - go func() { - for k, v := range gotSourceCodes { - gotSourceCodes[k] = SourceCode{ - Text: "", // remove text check. - Path: v.Path, - StartLine: v.StartLine, - StartColumn: v.StartColumn, - StartPos: v.StartPos, - TabWidth: v.TabWidth, - } - } - waitChan <- 1 - }() - <-waitChan - - assert.Equal(t, tt.wantThreads, gotThreads) - assert.Equal(t, tt.wantSourceCode, gotSourceCodes) - }) + if !reflect.DeepEqual(want, threads) { + t.Errorf("threads mismatch:\n got: %#v\nwant: %#v", threads, want) + } +} + +func TestBuildThreadsWindowsPaths(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\tC:/Users/dev/app/main.go:42 +0x1f\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + frame := threads["0"].Stacks[0] + if frame.Line != "42" { + t.Errorf("line = %q, want 42 (split at drive-letter colon?)", frame.Line) + } +} + +func TestBuildThreadsWindowsSourcePath(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\tC:/Users/dev/app/main.go:42 +0x1f\n" + + // File mode exercises the path bookkeeping even when unreadable. + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{mode: SourceCodeFile, contextLines: 8, tabWidth: 8}) + id := threads["0"].Stacks[0].SourceCodeID + if sources[id].Path != "C:/Users/dev/app/main.go" { + t.Errorf("source path = %q", sources[id].Path) + } +} + +func TestBuildThreadsNoDotFunctionLineDoesNotPanic(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "mysteryfunction(0x1)\n" + + "\t/tmp/x.go:5 +0x1\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + frame := threads["0"].Stacks[0] + if frame.FuncName != "mysteryfunction" || frame.Library != "" { + t.Errorf("frame = %+v", frame) + } +} + +func TestBuildThreadsElidedFramesMarker(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t/tmp/x.go:1 +0x1\n" + + "...additional frames elided...\n" + + "created by main.b in goroutine 2\n" + + "\t/tmp/x.go:9 +0x2\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + got := threads["0"].Stacks + if len(got) != 2 { + t.Fatalf("frames = %d, want 2 (elided marker desynced parser?): %+v", len(got), got) + } + if got[1].FuncName != "b" || got[1].Line != "9" { + t.Errorf("frame after elided marker = %+v", got[1]) + } +} + +func TestBuildThreadsGenerics(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "example.com/pkg.Map[go.shape.int,go.shape.string](0x1, 0x2)\n" + + "\t/tmp/gen.go:10 +0x1\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + frame := threads["0"].Stacks[0] + if frame.FuncName != "Map[go.shape.int,go.shape.string]" { + t.Errorf("FuncName = %q", frame.FuncName) + } + if frame.Library != "example.com/pkg" { + t.Errorf("Library = %q", frame.Library) + } +} + +func TestBuildThreadsGenericReceiver(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "example.com/pkg.(*Cache[go.shape.string]).Get(0x1, 0x2)\n" + + "\t/tmp/cache.go:33 +0x1\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + frame := threads["0"].Stacks[0] + if frame.FuncName != "Get" { + t.Errorf("FuncName = %q, want Get", frame.FuncName) + } + if frame.Library != "example.com/pkg.(*Cache[go.shape.string])" { + t.Errorf("Library = %q", frame.Library) + } +} + +func TestBuildThreadsAncestorSectionsIgnored(t *testing.T) { + // GODEBUG=tracebackancestors output appends ancestor sections after a + // goroutine's own frames; they are history, not live frames. + stack := "goroutine 5 [running]:\n" + + "main.worker()\n" + + "\t/tmp/x.go:10 +0x1\n" + + "[originating from goroutine 1]:\n" + + "main.spawner()\n" + + "\t/tmp/x.go:99 +0x2\n" + + "\n" + + "goroutine 6 [runnable]:\n" + + "main.other()\n" + + "\t/tmp/x.go:20 +0x3\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + if len(threads) != 2 { + t.Fatalf("threads = %d, want 2", len(threads)) + } + if got := threads["0"].Stacks; len(got) != 1 || got[0].FuncName != "worker" { + t.Errorf("ancestor frames leaked into thread 0: %+v", got) + } + if got := threads["1"].Stacks; len(got) != 1 || got[0].FuncName != "other" { + t.Errorf("thread after ancestor section wrong: %+v", got) + } +} + +func TestBuildThreadsDropsSDKOnlyThreads(t *testing.T) { + // A goroutine whose every frame is SDK-internal (e.g. the SDK's own + // send worker) is noise and is omitted; the app goroutine stays. + stack := "goroutine 1 [running]:\n" + + "main.caller()\n" + + "\t/tmp/app.go:30 +0x2\n" + + "\n" + + "goroutine 2 [select]:\n" + + "github.com/backtrace-labs/backtrace-go.(*Client).worker(0x1)\n" + + "\t/sdk/client.go:320 +0x1\n" + + "created by github.com/backtrace-labs/backtrace-go.startClient in goroutine 1\n" + + "\t/sdk/client.go:90 +0x2\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + if len(threads) != 1 { + t.Fatalf("threads = %d, want 1 (SDK-only goroutine kept?): %+v", len(threads), threads) + } + if _, ok := threads["0"]; !ok { + t.Error("app thread missing") + } +} + +func TestBuildThreadsSkipsSDKFrames(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "github.com/backtrace-labs/backtrace-go.Report(0x1, 0x2)\n" + + "\t/sdk/main.go:200 +0x1\n" + + "main.caller()\n" + + "\t/tmp/app.go:30 +0x2\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + got := threads["0"].Stacks + if len(got) != 1 || got[0].FuncName != "caller" { + t.Errorf("SDK frames not filtered: %+v", got) + } +} + +func TestBuildThreadsFrameWithoutLocation(t *testing.T) { + stack := "goroutine 17 [syscall]:\n" + + "goroutine running on other thread; stack unavailable\n" + + "\n" + + "goroutine 2 [runnable]:\n" + + "main.ok()\n" + + "\t/tmp/x.go:3 +0x1\n" + + threads, _, _ := buildThreads([]byte(stack), noSource()) + if len(threads) != 2 { + t.Fatalf("threads = %d, want 2", len(threads)) + } + if len(threads["0"].Stacks) != 0 { + t.Errorf("dangling function line produced a frame: %+v", threads["0"].Stacks) + } + if len(threads["1"].Stacks) != 1 { + t.Errorf("second thread frames = %+v", threads["1"].Stacks) + } +} + +func TestSourceContextExtraction(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "source.go") + var sb strings.Builder + for i := 1; i <= 30; i++ { + fmt.Fprintf(&sb, "line %d\n", i) + } + if err := os.WriteFile(path, []byte(sb.String()), 0o644); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\t" + path + ":15 +0x1f\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, + contextLines: 3, + tabWidth: 4, + }) + + id := threads["0"].Stacks[0].SourceCodeID + sc, ok := sources[id] + if !ok { + t.Fatalf("no source entry for id %q", id) + } + if sc.StartLine != 12 { + t.Errorf("StartLine = %d, want 12", sc.StartLine) + } + wantText := "line 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18" + if sc.Text != wantText { + t.Errorf("Text = %q, want %q", sc.Text, wantText) + } + if sc.TabWidth != 4 { + t.Errorf("TabWidth = %d, want 4", sc.TabWidth) + } +} + +func TestSourceContextClampsAtFileBounds(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tiny.go") + if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0o644); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\t" + path + ":1 +0x1f\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, + contextLines: 10, + tabWidth: 8, + }) + sc := sources[threads["0"].Stacks[0].SourceCodeID] + if sc.StartLine != 1 { + t.Errorf("StartLine = %d, want 1", sc.StartLine) + } + if !strings.HasPrefix(sc.Text, "one\ntwo\nthree") { + t.Errorf("Text = %q", sc.Text) + } +} + +func TestSourceFileModeEmbedsWholeFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "whole.go") + content := "alpha\nbeta\ngamma" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\t" + path + ":2 +0x1f\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeFile, + contextLines: 3, + tabWidth: 8, + }) + sc := sources[threads["0"].Stacks[0].SourceCodeID] + if sc.Text != content { + t.Errorf("Text = %q, want whole file", sc.Text) + } + if sc.StartLine != 1 { + t.Errorf("StartLine = %d, want 1", sc.StartLine) + } +} + +func TestSourceSnippetsDeduplicated(t *testing.T) { + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t/tmp/same.go:5 +0x1\n" + + "main.b()\n" + + "\t/tmp/same.go:5 +0x2\n" + + "main.c()\n" + + "\t/tmp/same.go:9 +0x3\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, + contextLines: 2, + tabWidth: 8, + }) + frames := threads["0"].Stacks + if frames[0].SourceCodeID != frames[1].SourceCodeID { + t.Error("same (path,line) not deduplicated") + } + if frames[0].SourceCodeID == frames[2].SourceCodeID { + t.Error("different lines share a snippet in context mode") + } + if len(sources) != 2 { + t.Errorf("source entries = %d, want 2", len(sources)) + } +} + +func TestParseThreadsFromStackLegacyWrapper(t *testing.T) { + threads, _ := ParseThreadsFromStack([]byte(stackFixture)) + if len(threads) != 4 { + t.Fatalf("threads = %d, want 4", len(threads)) + } + if !threads["0"].Fault { + t.Error("thread 0 not marked faulting") + } +} + +func TestSplitQualifiedFunction(t *testing.T) { + cases := []struct{ in, lib, fn string }{ + {"main.main()", "main", "main"}, + {"main.main", "main", "main"}, + {"testing.(*T).Run(0x1, {0x2, 0x9}, 0x3)", "testing.(*T)", "Run"}, + {"github.com/x/y.fn(0x1)", "github.com/x/y", "fn"}, + {"pkg.F[go.shape.int](0x1)", "pkg", "F[go.shape.int]"}, + {"pkg.(*Cache[go.shape.string]).Get(0x1)", "pkg.(*Cache[go.shape.string])", "Get"}, + {"panic({0x1?, 0x2?})", "", "panic"}, + {"noDotsAtAll(0x1)", "", "noDotsAtAll"}, + {"main.main.func1()", "main.main", "func1"}, + } + for _, c := range cases { + lib, fn := splitQualifiedFunction(c.in) + if lib != c.lib || fn != c.fn { + t.Errorf("splitQualifiedFunction(%q) = (%q, %q), want (%q, %q)", + c.in, lib, fn, c.lib, c.fn) + } } } diff --git a/tracer.go b/tracer.go index 67444b7..b556466 100644 --- a/tracer.go +++ b/tracer.go @@ -1,4 +1,4 @@ -// +build linux freebsd +//go:build linux || freebsd package bt @@ -8,6 +8,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "net/url" "os" @@ -58,6 +59,11 @@ type BTTracer struct { // Protects tracer state modification. m sync.RWMutex + // Protects the logger reference independently of m: Logf is called + // from paths that hold m (recursively read-locking a sync.RWMutex + // deadlocks once a writer is queued). + logMu sync.RWMutex + // Logs tracer execution status messages. logger Log @@ -69,12 +75,17 @@ type BTTracer struct { } type defaultLogger struct { + mu sync.Mutex logger *log.Logger level LogPriority } func (d *defaultLogger) Logf(level LogPriority, format string, v ...interface{}) { - if (d.level & level) == 0 { + d.mu.Lock() + enabled := (d.level & level) != 0 + d.mu.Unlock() + + if !enabled { return } @@ -82,6 +93,9 @@ func (d *defaultLogger) Logf(level LogPriority, format string, v ...interface{}) } func (d *defaultLogger) SetLogLevel(level LogPriority) { + d.mu.Lock() + defer d.mu.Unlock() + d.level = level } @@ -187,11 +201,14 @@ type PutOptions struct { // options: Modifies behavior of the Put action; see PutOptions documentation // for more details. func (t *BTTracer) ConfigurePut(endpoint, token string, options PutOptions) error { - if endpoint == "" || token == "" { - return errors.New("Endpoint must be non-empty") + if endpoint == "" { + return errors.New("endpoint must be non-empty") + } + if token == "" { + return errors.New("token must be non-empty") } - url, err := url.Parse(endpoint) + u, err := url.Parse(endpoint) if err != nil { return err } @@ -201,27 +218,30 @@ func (t *BTTracer) ConfigurePut(endpoint, token string, options PutOptions) erro // (unlikely) case of an unspecified scheme. We won't allow other // cases, like a port specified without a scheme, though, as per // RFC 3986. - if url.Host == "" { - if url.Path == "" { + if u.Host == "" { + if u.Path == "" { return errors.New("invalid URL specification: host " + "or path must be non-empty") } - url.Host = url.Path + u.Host = u.Path + u.Path = "" } - if url.Scheme == "" { - url.Scheme = defaultCoronerScheme + if u.Scheme == "" { + u.Scheme = defaultCoronerScheme } - if !strings.ContainsAny(url.Host, ":") { - url.Host += ":" + defaultCoronerPort + // Apply the default port IPv6-safely: Hostname() strips any + // brackets and JoinHostPort restores them as needed. + if _, _, portErr := net.SplitHostPort(u.Host); portErr != nil { + u.Host = net.JoinHostPort(u.Hostname(), defaultCoronerPort) } - url.Path = "post" - url.RawQuery = fmt.Sprintf("token=%s", token) + u.Path = "post" + u.RawQuery = url.Values{"token": {token}}.Encode() - t.put.endpoint = url.String() + t.put.endpoint = u.String() t.put.options = options t.Logf(LogDebug, "Put enabled (endpoint: %s, unlink: %v)\n", @@ -303,9 +323,14 @@ func (t *BTTracer) putSnapshotFile(path string) error { if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != 200 { + defer func() { + // Drain (bounded) so the keep-alive connection can be reused + // across PutDir loops. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("failed to upload: %s", resp.Status) } @@ -373,6 +398,9 @@ func (t *BTTracer) SetPipes(stdin io.Reader, stderr io.Writer) { // Sets the logger for the tracer. func (t *BTTracer) SetLogger(logger Log) { + t.logMu.Lock() + defer t.logMu.Unlock() + t.logger = logger } @@ -462,13 +490,19 @@ func (t *BTTracer) DefaultTraceOptions() *TraceOptions { // See bt.Tracer.Finalize(). func (t *BTTracer) Finalize(options []string) *exec.Cmd { + // Snapshot under the lock, then build and log without holding it: + // Logf must never run while m is held (recursive RLock). t.m.RLock() - defer t.m.RUnlock() + cmd := t.cmd + dir := t.outputDir + stdin := t.p.stdin + stderr := t.p.stderr + t.m.RUnlock() - tracer := exec.Command(t.cmd, options...) - tracer.Dir = t.outputDir - tracer.Stdin = t.p.stdin - tracer.Stderr = t.p.stderr + tracer := exec.Command(cmd, options...) + tracer.Dir = dir + tracer.Stdin = stdin + tracer.Stderr = stderr t.Logf(LogDebug, "Command: %v\n", tracer) @@ -476,17 +510,25 @@ func (t *BTTracer) Finalize(options []string) *exec.Cmd { } func (t *BTTracer) Logf(level LogPriority, format string, v ...interface{}) { - t.m.RLock() - defer t.m.RUnlock() - - t.logger.Logf(level, format, v...) + t.logMu.RLock() + logger := t.logger + t.logMu.RUnlock() + + if logger != nil { + // Called outside any BTTracer lock: format arguments may + // re-enter the tracer (e.g. %s on the tracer itself). + logger.Logf(level, format, v...) + } } func (t *BTTracer) SetLogLevel(level LogPriority) { - t.m.RLock() - defer t.m.RUnlock() + t.logMu.RLock() + logger := t.logger + t.logMu.RUnlock() - t.logger.SetLogLevel(level) + if logger != nil { + logger.SetLogLevel(level) + } } func (t *BTTracer) String() string { diff --git a/tracer_darwin_stub.go b/tracer_darwin_stub.go index cb2e554..e782d0b 100644 --- a/tracer_darwin_stub.go +++ b/tracer_darwin_stub.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "sync" + "time" ) //nolint:all @@ -85,9 +86,17 @@ type NewOptions struct { } // Returns a new object implementing the bt.Tracer and bt.TracerSig interfaces -// using the Backtrace debugging platform. +// using the Backtrace debugging platform. On macOS the tracer is a stub: +// methods are no-ops and Trace requests fail gracefully. func New(options NewOptions) *BTTracer { - return &BTTracer{} + return &BTTracer{ + defaultTraceOptions: TraceOptions{ + Faulted: true, + CallerOnly: false, + ErrClassification: true, + Timeout: time.Second * 120, + }, + } } type PutOptions struct { @@ -152,8 +161,12 @@ func (t *BTTracer) SetPipes(stdin io.Reader, stderr io.Writer) { func (t *BTTracer) SetLogger(logger Log) { } -// See bt.Tracer.AddOptions(). +// See bt.Tracer.AddOptions(). Honors the interface contract: a non-nil +// options slice is extended and returned. func (t *BTTracer) AddOptions(options []string, v ...string) []string { + if options != nil { + return append(options, v...) + } return nil } diff --git a/tracer_test.go b/tracer_test.go new file mode 100644 index 0000000..44cff4d --- /dev/null +++ b/tracer_test.go @@ -0,0 +1,85 @@ +//go:build linux || freebsd + +package bt + +import ( + "io" + "log" + "strings" + "sync" + "testing" +) + +func TestConfigurePutURLForms(t *testing.T) { + cases := []struct { + name string + endpoint string + token string + want string // "" means an error is expected + }{ + {"host only", "yourcompany.sp.backtrace.io", "tok", + "https://yourcompany.sp.backtrace.io:6098/post?token=tok"}, + {"scheme and port kept", "http://host.example.com:1234", "tok", + "http://host.example.com:1234/post?token=tok"}, + {"ipv6 gets default port", "https://[::1]", "tok", + "https://[::1]:6098/post?token=tok"}, + {"token escaped", "https://host.example.com:6098", "a&b #c", + "https://host.example.com:6098/post?token=a%26b+%23c"}, + {"empty endpoint", "", "tok", ""}, + {"empty token", "https://host.example.com", "", ""}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tr := New(NewOptions{}) + err := tr.ConfigurePut(c.endpoint, c.token, PutOptions{}) + if c.want == "" { + if err == nil { + t.Fatalf("expected error, got endpoint %q", tr.put.endpoint) + } + if c.endpoint != "" && !strings.Contains(err.Error(), "token") { + t.Errorf("empty-token error misleading: %v", err) + } + return + } + if err != nil { + t.Fatalf("ConfigurePut: %v", err) + } + if tr.put.endpoint != c.want { + t.Errorf("endpoint = %q, want %q", tr.put.endpoint, c.want) + } + }) + } +} + +// TestTracerLoggerConcurrency locks in the fix for the recursive-RLock +// deadlock: Finalize/Logf/String must be callable concurrently with +// SetLogger/SetLogLevel. Run with -race; a regression deadlocks or races. +func TestTracerLoggerConcurrency(t *testing.T) { + tr := New(NewOptions{}) + + quiet := &defaultLogger{logger: log.New(io.Discard, "", 0), level: LogError} + + var wg sync.WaitGroup + for g := 0; g < 4; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + tr.SetLogLevel(LogMax) + tr.SetLogger(quiet) + } + }() + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 500; i++ { + _ = tr.Finalize([]string{"--noop"}) + tr.Logf(LogDebug, "tracer: %s\n", tr) + _ = tr.String() + tr.SetTracerPath("/opt/backtrace/bin/ptrace") + } + }() + } + wg.Wait() +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..4e2fd3a --- /dev/null +++ b/transport.go @@ -0,0 +1,216 @@ +package bt + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + neturl "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// errRateLimited is returned by httpTransport.send while submissions are +// paused due to a server 429 response. +var errRateLimited = errors.New("bt: rate limited by server, report dropped") + +// rateLimitFallback is the pause applied after a 429 without a parsable +// Retry-After header. +const rateLimitFallback = time.Minute + +// httpTransport delivers serialized reports over HTTP. It is safe for +// concurrent use, applies the configured timeout, verifies response status, +// honors 429 Retry-After, and drains response bodies so connections are +// reused. +type httpTransport struct { + client *http.Client + + mu sync.Mutex + pauseUntil time.Time +} + +func newHTTPTransport(client *http.Client, timeout time.Duration) *httpTransport { + if client == nil { + client = &http.Client{Timeout: timeout} + } + return &httpTransport{client: client} +} + +// rateLimited reports whether submissions are currently paused. +func (t *httpTransport) rateLimited() bool { + t.mu.Lock() + defer t.mu.Unlock() + return time.Now().Before(t.pauseUntil) +} + +func (t *httpTransport) pause(d time.Duration) { + t.mu.Lock() + defer t.mu.Unlock() + t.pauseUntil = time.Now().Add(d) +} + +// maxAttachmentSize caps individual attachment uploads; larger files are +// skipped with a diagnostic. +const maxAttachmentSize = 10 << 20 // 10 MiB + +// send POSTs body to url. Attachments, when present, switch the request to +// the documented multipart form ("upload_file" part for the report JSON, +// "attachment_" parts for files). A non-2xx response is an error; a +// 429 additionally pauses future sends until the server's Retry-After +// deadline. +func (t *httpTransport) send(url string, body []byte, attachments []string, d diag) error { + if t.rateLimited() { + return errRateLimited + } + + var ( + reqBody io.Reader = bytes.NewReader(body) + contentType = "application/json" + ) + if len(attachments) > 0 { + multipartBody, multipartType, err := buildMultipart(body, attachments, d) + if err != nil { + return fmt.Errorf("bt: building multipart request: %w", err) + } + reqBody, contentType = multipartBody, multipartType + } + + req, err := http.NewRequest(http.MethodPost, url, reqBody) + if err != nil { + return fmt.Errorf("bt: building request: %w", err) + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("User-Agent", "backtrace-go/"+Version) + + resp, err := t.client.Do(req) + if err != nil { + // *url.Error embeds the full URL (token included); scrub it + // before the error reaches any log. + var ue *neturl.Error + if errors.As(err, &ue) { + ue.URL = redactURL(ue.URL) + } + return fmt.Errorf("bt: sending report: %w", err) + } + defer func() { + // Drain so the keep-alive connection can be reused. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + }() + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return nil + case resp.StatusCode == http.StatusTooManyRequests: + d := retryAfter(resp) + t.pause(d) + return fmt.Errorf("bt: server rate limit (429), pausing submissions for %s", d) + default: + return fmt.Errorf("bt: server rejected report: %s", resp.Status) + } +} + +// buildMultipart assembles the multipart body documented for Backtrace +// submissions: the report JSON in an "upload_file" part plus one +// "attachment_" part per readable attachment. Unreadable or +// oversized files are skipped, never failing the report itself. +func buildMultipart(body []byte, attachments []string, d diag) (io.Reader, string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + reportPart, err := w.CreateFormFile("upload_file", "report.json") + if err != nil { + return nil, "", err + } + if _, err := reportPart.Write(body); err != nil { + return nil, "", err + } + + seen := map[string]int{} + for _, path := range attachments { + info, err := os.Stat(path) + if err != nil { + d.logf("attachment %q skipped: %v", path, err) + continue + } + if info.Size() > maxAttachmentSize { + d.logf("attachment %q skipped: %d bytes exceeds the %d byte limit", + path, info.Size(), maxAttachmentSize) + continue + } + file, err := os.Open(path) + if err != nil { + d.logf("attachment %q skipped: %v", path, err) + continue + } + // Attachments from different directories may share a + // basename; uniquify so no part overwrites another. + name := filepath.Base(path) + if n := seen[name]; n > 0 { + ext := filepath.Ext(name) + name = fmt.Sprintf("%s_%d%s", strings.TrimSuffix(name, ext), n, ext) + } + seen[filepath.Base(path)]++ + part, err := w.CreateFormFile("attachment_"+name, name) + if err == nil { + _, err = io.Copy(part, file) + } + file.Close() + if err != nil { + return nil, "", err + } + } + + if err := w.Close(); err != nil { + return nil, "", err + } + return &buf, w.FormDataContentType(), nil +} + +// redactURL hides the submission token in diagnostics output, both in the +// ?token= query form and in the submit.backtrace.io/{universe}/{token}/{fmt} +// path form. +func redactURL(u string) string { + parsed, err := neturl.Parse(u) + if err != nil { + return u + } + q := parsed.Query() + if q.Get("token") != "" { + q.Set("token", "REDACTED") + parsed.RawQuery = q.Encode() + } + if strings.EqualFold(parsed.Hostname(), "submit.backtrace.io") { + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(segments) >= 3 { + segments[1] = "REDACTED" + parsed.Path = "/" + strings.Join(segments, "/") + } + } + return parsed.String() +} + +// retryAfter parses a Retry-After header given either as delay seconds or as +// an HTTP date, falling back to rateLimitFallback. +func retryAfter(resp *http.Response) time.Duration { + header := resp.Header.Get("Retry-After") + if header == "" { + return rateLimitFallback + } + if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 { + return time.Duration(seconds) * time.Second + } + if at, err := http.ParseTime(header); err == nil { + if d := time.Until(at); d > 0 { + return d + } + return 0 + } + return rateLimitFallback +} diff --git a/transport_test.go b/transport_test.go new file mode 100644 index 0000000..b9b3bb8 --- /dev/null +++ b/transport_test.go @@ -0,0 +1,69 @@ +package bt + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func TestRetryAfterParsing(t *testing.T) { + mk := func(value string) *http.Response { + h := http.Header{} + if value != "" { + h.Set("Retry-After", value) + } + return &http.Response{Header: h} + } + + if d := retryAfter(mk("30")); d != 30*time.Second { + t.Errorf("seconds form = %v, want 30s", d) + } + if d := retryAfter(mk("")); d != rateLimitFallback { + t.Errorf("missing header = %v, want fallback", d) + } + if d := retryAfter(mk("garbage")); d != rateLimitFallback { + t.Errorf("garbage header = %v, want fallback", d) + } + future := time.Now().Add(90 * time.Second).UTC().Format(http.TimeFormat) + if d := retryAfter(mk(future)); d < 80*time.Second || d > 91*time.Second { + t.Errorf("http-date form = %v, want ~90s", d) + } + past := time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat) + if d := retryAfter(mk(past)); d != 0 { + t.Errorf("past http-date = %v, want 0", d) + } +} + +func TestTransportPause(t *testing.T) { + tr := newHTTPTransport(nil, time.Second) + if tr.rateLimited() { + t.Error("fresh transport is rate limited") + } + tr.pause(time.Minute) + if !tr.rateLimited() { + t.Error("pause not applied") + } + if err := tr.send("http://127.0.0.1:1/unused", []byte("{}"), nil, diag{}); err != errRateLimited { + t.Errorf("send while paused = %v, want errRateLimited", err) + } +} + +func TestRedactURL(t *testing.T) { + in := "https://example.com/post?format=json&token=supersecret" + out := redactURL(in) + if out == in { + t.Error("token not redacted") + } + if want := "token=REDACTED"; !strings.Contains(out, want) { + t.Errorf("redacted URL %q missing %q", out, want) + } + // submit.backtrace.io embeds the token as the second path segment. + if got := redactURL("https://submit.backtrace.io/universe/secret-token/json"); got != "https://submit.backtrace.io/universe/REDACTED/json" { + t.Errorf("submit path token not redacted: %q", got) + } + // Other tokenless URLs pass through unchanged. + if got := redactURL("https://uni.sp.backtrace.io/api/post"); got != "https://uni.sp.backtrace.io/api/post" { + t.Errorf("tokenless URL modified: %q", got) + } +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..8b7a997 --- /dev/null +++ b/version.go @@ -0,0 +1,13 @@ +package bt + +import "fmt" + +// SDK version. Follows semantic versioning. +const ( + VersionMajor = 1 + VersionMinor = 1 + VersionPatch = 0 +) + +// Version is the canonical SDK version string reported with every payload. +var Version = fmt.Sprintf("%d.%d.%d", VersionMajor, VersionMinor, VersionPatch) From 490a97012b2f558bd2efca8d3e5f60befbf7694e Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 11:48:47 -0400 Subject: [PATCH 02/13] fix(client,transport): fail-closed BeforeSend, enforced attachment cap, bt-breadcrumbs - A panicking BeforeSend hook now drops the report instead of sending it half scrubbed. The drop is counted in DroppedReports - Attachment 10 MiB cap enforced at read time. - Breadcrumbs also sent as the bt-breadcrumbs-0 attachment the Backtrace UI reads: scrubbing the annotation in BeforeSend suppresses it - Token redacted in 2-segment submit.backtrace.io paths and in transport errors (*url.Error embeds the full URL) - Tests: multipart test server, panic-report queue-full retry, Flush retry after full queue, attachment collision cases --- client.go | 25 ++++++-- client_test.go | 151 +++++++++++++++++++++++++++++++++++++++++++--- main_test.go | 70 ++++++++++++++++----- transport.go | 70 +++++++++++++++++---- transport_test.go | 6 +- 5 files changed, 283 insertions(+), 39 deletions(-) diff --git a/client.go b/client.go index 9e4a2e8..06917fe 100644 --- a/client.go +++ b/client.go @@ -233,6 +233,9 @@ func (c *Client) Flush(timeout time.Duration) bool { // Subsequent reports are dropped (and counted). Close is idempotent. // Call Flush first if you need a bounded wait; Close waits for the full // drain (each send is bounded by the configured timeout). +// +// Close and Flush must not be called from inside a BeforeSend hook: the +// hook runs on the worker goroutine those calls wait on. func (c *Client) Close() { c.qmu.Lock() if !c.closed { @@ -431,23 +434,37 @@ func (c *Client) processAndSend(qr *queuedReport) { return } + // Breadcrumbs also travel as the bt-breadcrumbs-0 attachment, the file + // the Backtrace UI's breadcrumb view reads (same format as the other + // Backtrace SDKs). Sourced from the annotation so a BeforeSend hook + // that scrubbed or removed breadcrumbs is respected. + var inline []inlinePart + if crumbs, ok := report.Annotations["breadcrumbs"].([]Breadcrumb); ok && len(crumbs) > 0 { + if data, err := json.Marshal(crumbs); err == nil { + inline = append(inline, inlinePart{name: "bt-breadcrumbs-0", data: data}) + } + } + if cfg.Debug { pretty, _ := json.MarshalIndent(report.toWire(), "", " ") d.logf("sending report %s to %s\n%s", report.UUID, redactURL(cfg.submissionURL()), pretty) } - if err := c.transport.send(cfg.submissionURL(), body, report.Attachments, d); err != nil { + if err := c.transport.send(cfg.submissionURL(), body, report.Attachments, inline, d); err != nil { c.dropped.Add(1) d.logf("report %s dropped: %v", report.UUID, err) } } -// runBeforeSend isolates user hook panics from the worker. +// runBeforeSend isolates user hook panics from the worker. A panicking hook +// drops the report (fail closed): hooks exist to scrub sensitive data, and a +// report in a half-scrubbed state must never leave the process. func (c *Client) runBeforeSend(hook func(*ReportData) *ReportData, report *ReportData, d diag) (out *ReportData) { defer func() { if r := recover(); r != nil { - d.logf("BeforeSend panicked (%v); sending report unmodified", r) - out = report + c.dropped.Add(1) + d.logf("BeforeSend panicked (%v); dropping report %s", r, report.UUID) + out = nil } }() return hook(report) diff --git a/client_test.go b/client_test.go index 3f6e871..23401d3 100644 --- a/client_test.go +++ b/client_test.go @@ -230,15 +230,23 @@ func TestBeforeSendDrops(t *testing.T) { } } -func TestBeforeSendPanicIsContained(t *testing.T) { +// TestBeforeSendPanicDropsReport: a panicking hook must fail closed — the +// report may be half-scrubbed, so it is dropped and counted, never sent. +func TestBeforeSendPanicDropsReport(t *testing.T) { c, rs := newTestClient(t, func(cfg *Config) { - cfg.BeforeSend = func(r *ReportData) *ReportData { panic("hook bug") } + cfg.BeforeSend = func(r *ReportData) *ReportData { + r.Attributes["half"] = "scrubbed" + panic("hook bug") + } }) - c.ReportMessage("survives", nil) + c.ReportMessage("must not leave the process", nil) c.Flush(5 * time.Second) - if rs.count() != 1 { - t.Fatalf("report lost to BeforeSend panic: %d", rs.count()) + if rs.count() != 0 { + t.Fatalf("half-scrubbed report was sent despite BeforeSend panic: %d", rs.count()) + } + if c.DroppedReports() != 1 { + t.Errorf("dropped counter = %d, want 1", c.DroppedReports()) } } @@ -309,6 +317,111 @@ func TestBreadcrumbsRingAndAnnotation(t *testing.T) { if first["level"] != "info" || first["type"] != "manual" { t.Errorf("breadcrumb defaults not applied: %v", first) } + + // Breadcrumbs also ship as the bt-breadcrumbs-0 attachment the + // Backtrace UI reads (same schema as the other Backtrace SDKs). + atts := rs.lastAttachments() + raw, ok := atts["attachment_bt-breadcrumbs-0"] + if !ok { + t.Fatalf("bt-breadcrumbs-0 attachment missing; parts: %v", atts) + } + var fileCrumbs []map[string]interface{} + if err := json.Unmarshal([]byte(raw), &fileCrumbs); err != nil { + t.Fatalf("breadcrumb attachment is not a JSON array: %v", err) + } + if len(fileCrumbs) != 8 || fileCrumbs[0]["message"] != "crumb 4" { + t.Errorf("breadcrumb attachment content wrong: %d entries, first %v", + len(fileCrumbs), fileCrumbs[0]) + } +} + +// TestPanicReportRetriesFullQueue pins the bounded blocking enqueue for +// panic reports: with the queue full they retry instead of dropping. +func TestPanicReportRetriesFullQueue(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, func(cfg *Config) { + cfg.QueueSize = 1 + }) + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + c.ReportMessage("occupies the worker", nil) + select { + case <-rs.entered: // worker is now stuck inside the handler + case <-time.After(3 * time.Second): + t.Fatal("worker never reached the transport") + } + c.ReportMessage("fills the queue", nil) + + done := make(chan struct{}) + go func() { + c.ReportPanicValue("panic while queue full", nil) + close(done) + }() + + select { + case <-done: + t.Fatal("panic report returned immediately: dropped instead of retrying") + case <-time.After(100 * time.Millisecond): + // Still retrying, as intended. + } + + unblock() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("panic report never enqueued after queue freed") + } + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + if rs.count() != 3 { + t.Errorf("reports delivered = %d, want 3 (panic report lost?)", rs.count()) + } +} + +// TestFlushSucceedsAfterQueueFullRetry pins Flush's retry loop: a queue that +// is full when Flush is called must not produce a false negative once it +// drains within the timeout. +func TestFlushSucceedsAfterQueueFullRetry(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, func(cfg *Config) { + cfg.QueueSize = 1 + }) + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + c.ReportMessage("occupies the worker", nil) + select { + case <-rs.entered: + case <-time.After(3 * time.Second): + t.Fatal("worker never reached the transport") + } + c.ReportMessage("fills the queue", nil) + + res := make(chan bool, 1) + go func() { res <- c.Flush(10 * time.Second) }() + time.Sleep(50 * time.Millisecond) // let Flush hit the queue-full retry path + unblock() + + select { + case ok := <-res: + if !ok { + t.Error("Flush returned false although the queue drained within the timeout") + } + case <-time.After(5 * time.Second): + t.Fatal("Flush stuck") + } } func TestServerErrorCountsAsDropped(t *testing.T) { @@ -374,6 +487,22 @@ func TestAttachmentsMultipartSubmission(t *testing.T) { if err := os.WriteFile(attachmentPath, []byte("log line 1\nlog line 2\n"), 0o644); err != nil { t.Fatal(err) } + // Same basename in another directory: must arrive under a distinct + // part name instead of overwriting the first attachment. + dupDir := filepath.Join(dir, "dup") + if err := os.MkdirAll(dupDir, 0o755); err != nil { + t.Fatal(err) + } + dupPath := filepath.Join(dupDir, "app.log") + if err := os.WriteFile(dupPath, []byte("other content\n"), 0o644); err != nil { + t.Fatal(err) + } + // A file whose NATURAL basename collides with a generated candidate: + // uniquification must probe past it instead of overwriting. + natPath := filepath.Join(dir, "app_1.log") + if err := os.WriteFile(natPath, []byte("natural\n"), 0o644); err != nil { + t.Fatal(err) + } type received struct { reportJSON map[string]interface{} @@ -412,7 +541,7 @@ func TestAttachmentsMultipartSubmission(t *testing.T) { c, err := NewClient(Config{ Endpoint: srv.URL, Token: "attach-test", - AttachmentPaths: []string{attachmentPath, filepath.Join(dir, "missing.txt")}, + AttachmentPaths: []string{attachmentPath, natPath, dupPath, filepath.Join(dir, "missing.txt")}, }) if err != nil { t.Fatal(err) @@ -435,8 +564,14 @@ func TestAttachmentsMultipartSubmission(t *testing.T) { if rec.attachments["attachment_app.log"] != "log line 1\nlog line 2\n" { t.Errorf("attachment content = %q", rec.attachments["attachment_app.log"]) } - if len(rec.attachments) != 1 { - t.Errorf("unreadable attachment not skipped: %v", rec.attachments) + if rec.attachments["attachment_app_1.log"] != "natural\n" { + t.Errorf("natural basename lost its name: %v", rec.attachments) + } + if rec.attachments["attachment_app_2.log"] != "other content\n" { + t.Errorf("duplicate basename not uniquified past natural collision: %v", rec.attachments) + } + if len(rec.attachments) != 3 { + t.Errorf("attachments = %v (unreadable file not skipped, or parts collided)", rec.attachments) } case <-time.After(time.Second): t.Fatal("no submission received") diff --git a/main_test.go b/main_test.go index af090c6..0a4679c 100644 --- a/main_test.go +++ b/main_test.go @@ -8,22 +8,26 @@ import ( "net/http/httptest" "os" "regexp" + "strings" "sync" "testing" "time" ) -// recordingServer captures submitted reports for assertions. +// recordingServer captures submitted reports (JSON or multipart) for +// assertions. type recordingServer struct { - mu sync.Mutex - payloads []map[string]interface{} - status int - block chan struct{} // when non-nil, handler blocks until closed - srv *httptest.Server + mu sync.Mutex + payloads []map[string]interface{} + attachments []map[string]string // parallel to payloads; part name -> content + status int + block chan struct{} // when non-nil, handler blocks until closed + entered chan struct{} // signaled when a handler starts blocking + srv *httptest.Server } func newRecordingServer() *recordingServer { - rs := &recordingServer{status: http.StatusOK} + rs := &recordingServer{status: http.StatusOK, entered: make(chan struct{}, 64)} rs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rs.mu.Lock() block := rs.block @@ -31,23 +35,60 @@ func newRecordingServer() *recordingServer { rs.mu.Unlock() if block != nil { + select { + case rs.entered <- struct{}{}: + default: + } <-block } - body, err := io.ReadAll(r.Body) - if err == nil { - payload := map[string]interface{}{} - if json.Unmarshal(body, &payload) == nil { - rs.mu.Lock() - rs.payloads = append(rs.payloads, payload) - rs.mu.Unlock() + var payload map[string]interface{} + atts := map[string]string{} + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") { + if err := r.ParseMultipartForm(64 << 20); err == nil { + for field, headers := range r.MultipartForm.File { + f, err := headers[0].Open() + if err != nil { + continue + } + content, _ := io.ReadAll(f) + f.Close() + if field == "upload_file" { + m := map[string]interface{}{} + if json.Unmarshal(content, &m) == nil { + payload = m + } + } else { + atts[field] = string(content) + } + } + } + } else if body, err := io.ReadAll(r.Body); err == nil { + m := map[string]interface{}{} + if json.Unmarshal(body, &m) == nil { + payload = m } } + if payload != nil { + rs.mu.Lock() + rs.payloads = append(rs.payloads, payload) + rs.attachments = append(rs.attachments, atts) + rs.mu.Unlock() + } w.WriteHeader(status) })) return rs } +func (rs *recordingServer) lastAttachments() map[string]string { + rs.mu.Lock() + defer rs.mu.Unlock() + if len(rs.attachments) == 0 { + return nil + } + return rs.attachments[len(rs.attachments)-1] +} + func (rs *recordingServer) count() int { rs.mu.Lock() defer rs.mu.Unlock() @@ -67,6 +108,7 @@ func (rs *recordingServer) reset() { rs.mu.Lock() defer rs.mu.Unlock() rs.payloads = nil + rs.attachments = nil } func attrsOf(t *testing.T, payload map[string]interface{}) map[string]interface{} { diff --git a/transport.go b/transport.go index 4e2fd3a..5145b1e 100644 --- a/transport.go +++ b/transport.go @@ -59,12 +59,18 @@ func (t *httpTransport) pause(d time.Duration) { // skipped with a diagnostic. const maxAttachmentSize = 10 << 20 // 10 MiB +// inlinePart is an in-memory attachment (e.g. the bt-breadcrumbs-0 file). +type inlinePart struct { + name string + data []byte +} + // send POSTs body to url. Attachments, when present, switch the request to // the documented multipart form ("upload_file" part for the report JSON, // "attachment_" parts for files). A non-2xx response is an error; a // 429 additionally pauses future sends until the server's Retry-After // deadline. -func (t *httpTransport) send(url string, body []byte, attachments []string, d diag) error { +func (t *httpTransport) send(url string, body []byte, attachments []string, inline []inlinePart, d diag) error { if t.rateLimited() { return errRateLimited } @@ -73,8 +79,8 @@ func (t *httpTransport) send(url string, body []byte, attachments []string, d di reqBody io.Reader = bytes.NewReader(body) contentType = "application/json" ) - if len(attachments) > 0 { - multipartBody, multipartType, err := buildMultipart(body, attachments, d) + if len(attachments) > 0 || len(inline) > 0 { + multipartBody, multipartType, err := buildMultipart(body, attachments, inline, d) if err != nil { return fmt.Errorf("bt: building multipart request: %w", err) } @@ -118,9 +124,11 @@ func (t *httpTransport) send(url string, body []byte, attachments []string, d di // buildMultipart assembles the multipart body documented for Backtrace // submissions: the report JSON in an "upload_file" part plus one -// "attachment_" part per readable attachment. Unreadable or -// oversized files are skipped, never failing the report itself. -func buildMultipart(body []byte, attachments []string, d diag) (io.Reader, string, error) { +// "attachment_" part per readable attachment. Unreadable, +// non-regular, or oversized files are skipped, never failing the report +// itself. The size cap is enforced at read time (a file may grow between +// stat and copy). +func buildMultipart(body []byte, attachments []string, inline []inlinePart, d diag) (io.Reader, string, error) { var buf bytes.Buffer w := multipart.NewWriter(&buf) @@ -132,13 +140,29 @@ func buildMultipart(body []byte, attachments []string, d diag) (io.Reader, strin return nil, "", err } - seen := map[string]int{} + used := map[string]bool{} + for _, p := range inline { + used[p.name] = true + part, err := w.CreateFormFile("attachment_"+p.name, p.name) + if err == nil { + _, err = part.Write(p.data) + } + if err != nil { + return nil, "", err + } + } + for _, path := range attachments { info, err := os.Stat(path) if err != nil { d.logf("attachment %q skipped: %v", path, err) continue } + // FIFOs and devices would block or read unbounded data. + if !info.Mode().IsRegular() { + d.logf("attachment %q skipped: not a regular file", path) + continue + } if info.Size() > maxAttachmentSize { d.logf("attachment %q skipped: %d bytes exceeds the %d byte limit", path, info.Size(), maxAttachmentSize) @@ -152,19 +176,39 @@ func buildMultipart(body []byte, attachments []string, d diag) (io.Reader, strin // Attachments from different directories may share a // basename; uniquify so no part overwrites another. name := filepath.Base(path) - if n := seen[name]; n > 0 { + if used[name] { ext := filepath.Ext(name) - name = fmt.Sprintf("%s_%d%s", strings.TrimSuffix(name, ext), n, ext) + stem := strings.TrimSuffix(name, ext) + for i := 1; ; i++ { + candidate := fmt.Sprintf("%s_%d%s", stem, i, ext) + if !used[candidate] { + name = candidate + break + } + } } - seen[filepath.Base(path)]++ + used[name] = true + + // Remember the buffer position so an over-limit file can be + // rolled back cleanly (part boundaries are only written by + // CreateFormFile/Close, so truncating to the mark removes the + // whole part). + mark := buf.Len() part, err := w.CreateFormFile("attachment_"+name, name) + var copied int64 if err == nil { - _, err = io.Copy(part, file) + copied, err = io.Copy(part, io.LimitReader(file, maxAttachmentSize+1)) } file.Close() if err != nil { return nil, "", err } + if copied > maxAttachmentSize { + buf.Truncate(mark) + delete(used, name) + d.logf("attachment %q skipped: grew beyond the %d byte limit while reading", + path, maxAttachmentSize) + } } if err := w.Close(); err != nil { @@ -188,7 +232,9 @@ func redactURL(u string) string { } if strings.EqualFold(parsed.Hostname(), "submit.backtrace.io") { segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") - if len(segments) >= 3 { + // The token is always the second path segment + // ({universe}/{token}[/{format}]). + if len(segments) >= 2 { segments[1] = "REDACTED" parsed.Path = "/" + strings.Join(segments, "/") } diff --git a/transport_test.go b/transport_test.go index b9b3bb8..62e7982 100644 --- a/transport_test.go +++ b/transport_test.go @@ -44,7 +44,7 @@ func TestTransportPause(t *testing.T) { if !tr.rateLimited() { t.Error("pause not applied") } - if err := tr.send("http://127.0.0.1:1/unused", []byte("{}"), nil, diag{}); err != errRateLimited { + if err := tr.send("http://127.0.0.1:1/unused", []byte("{}"), nil, nil, diag{}); err != errRateLimited { t.Errorf("send while paused = %v, want errRateLimited", err) } } @@ -62,6 +62,10 @@ func TestRedactURL(t *testing.T) { if got := redactURL("https://submit.backtrace.io/universe/secret-token/json"); got != "https://submit.backtrace.io/universe/REDACTED/json" { t.Errorf("submit path token not redacted: %q", got) } + // Two-segment form (no trailing /json) still carries the token. + if got := redactURL("https://submit.backtrace.io/universe/secret-token"); got != "https://submit.backtrace.io/universe/REDACTED" { + t.Errorf("2-segment submit path token not redacted: %q", got) + } // Other tokenless URLs pass through unchanged. if got := redactURL("https://uni.sp.backtrace.io/api/post"); got != "https://uni.sp.backtrace.io/api/post" { t.Errorf("tokenless URL modified: %q", got) From defb4da4e043ffb5b592df6e5ac4a4be1aa3b404 Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 11:50:02 -0400 Subject: [PATCH 03/13] fix(config): endpoint URL hygiene and validation Trailing-slash endpoints no longer produce "//post" : an endpoint carrying a query string together with Token is rejected at NewClient (previously produced a malformed submission URL and silently dropped every report). --- config.go | 18 +++++++++++++----- config_test.go | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/config.go b/config.go index 9669f2c..72a4a0f 100644 --- a/config.go +++ b/config.go @@ -108,8 +108,9 @@ type Config struct { // AttachmentPaths lists files attached to every report (multipart // submission, one "attachment_" part per file). Unreadable - // files are skipped with a debug log. Per-report changes can be made - // in BeforeSend via ReportData.Attachments. + // or non-regular files and files larger than 10 MiB are skipped with + // a debug log. Per-report changes can be made in BeforeSend via + // ReportData.Attachments. AttachmentPaths []string // SampleRate is the fraction of reports actually sent, in [0.0, 1.0]. @@ -119,8 +120,10 @@ type Config struct { // BeforeSend, when set, runs just before a report is serialized. // Return the (optionally modified) report to send it, or nil to drop - // it. Runs on the SDK's worker goroutine; a panic inside the hook is - // recovered and logged, and the report is sent unmodified. + // it. Runs on the SDK's worker goroutine — do not call Flush or Close + // from inside the hook. A panic inside the hook is recovered and the + // report is DROPPED (never sent half-scrubbed) and counted in + // DroppedReports. BeforeSend func(report *ReportData) *ReportData // MaxErrorDepth caps error-chain unwrapping. Default: 100. Negative @@ -207,6 +210,9 @@ func (c Config) validate() error { if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("bt: Config.Endpoint must be an http(s) URL, got %q", c.Endpoint) } + if c.Token != "" && u.RawQuery != "" { + return fmt.Errorf("bt: Config.Endpoint must not carry a query string when Token is set, got %q", c.Endpoint) + } return nil } @@ -229,5 +235,7 @@ func (c Config) submissionURL() string { v := url.Values{} v.Set("format", "json") v.Set("token", c.Token) - return fmt.Sprintf("%s/post?%s", c.Endpoint, v.Encode()) + // Trim trailing slashes (the form users paste from a browser) so the + // appended path never produces "//post". + return fmt.Sprintf("%s/post?%s", strings.TrimRight(c.Endpoint, "/"), v.Encode()) } diff --git a/config_test.go b/config_test.go index 21cf9eb..5fac2ea 100644 --- a/config_test.go +++ b/config_test.go @@ -98,6 +98,24 @@ func TestSubmissionURLForms(t *testing.T) { if strings.Contains(got, "a&b") || !strings.Contains(got, "format=json") { t.Errorf("token not escaped or format missing: %q", got) } + + // Trailing slash (the form users paste from a browser) must not + // produce "//post". + cfg = Config{Endpoint: "https://uni.sp.backtrace.io/", Token: "tok"}.normalize() + if got := cfg.submissionURL(); strings.Contains(got, "//post") { + t.Errorf("trailing slash produced double slash: %q", got) + } +} + +func TestConfigValidateRejectsQueryWithToken(t *testing.T) { + err := (Config{Endpoint: "https://host:6098?x=1", Token: "tok"}).validate() + if err == nil { + t.Error("endpoint with query + token accepted; submissionURL would be malformed") + } + // Without a token the endpoint is used verbatim, so a query is fine. + if err := (Config{Endpoint: "https://host/post?format=json&token=t"}).validate(); err != nil { + t.Errorf("verbatim endpoint with query rejected: %v", err) + } } func TestConfigValidate(t *testing.T) { From f84d7e441545397c7704a62b7d38432da120bd56 Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 11:50:46 -0400 Subject: [PATCH 04/13] fix(attributes): probe Windows CPU via reg query instead of wmic wmic is removed from Windows 11 24H2 / Server 2025. Shared reg-output parser for the GUID and CPU probes, with unit tests. --- attributes.go | 31 ++++++++++++++++++++----------- attributes_test.go | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/attributes.go b/attributes.go index 8f0d4e9..2955886 100644 --- a/attributes.go +++ b/attributes.go @@ -21,7 +21,9 @@ var ( freebsdGUIDCommand = []string{"sh", "-c", "kenv -q smbios.system.uuid || sysctl -n kern.hostuuid"} darwinGUIDCommand = []string{"sh", "-c", "ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID | awk -F'= \"' '{print $2}' | tr -d '\"' | tr -d '\n'"} - windowsCPUCommand = []string{"wmic", "CPU", "get", "NAME"} + // reg query instead of wmic: wmic is removed from Windows 11 24H2 / + // Server 2025. + windowsCPUCommand = []string{"reg", "query", `HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "/v", "ProcessorNameString"} linuxCPUCommand = []string{"sh", "-c", "lscpu | grep \"Model name\" | awk -F':' '{print $2}' | sed 's/^[[:space:]]*//'"} darwinCPUCommand = []string{"sh", "-c", "sysctl -n machdep.cpu.brand_string | tr -d '\n'"} freebsdCPUCommand = []string{"sh", "-c", "sysctl -n hw.model"} @@ -115,22 +117,14 @@ func machineAttributes(d diag) map[string]interface{} { if output := execCommand(guidCommand, d); output != "" { if runtime.GOOS == "windows" { - // reg query output: - // HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography - // MachineGuid REG_SZ xxxxxxxx-xxxx-... - if fields := strings.Fields(output); len(fields) > 0 { - output = strings.Trim(fields[len(fields)-1], "{}") - } + output = strings.Trim(parseWindowsRegValue(output), "{}") } attrs["guid"] = strings.TrimSpace(output) } if output := execCommand(cpuCommand, d); output != "" { if runtime.GOOS == "windows" { - // wmic output: header line "NAME" then the value. - if lines := strings.Split(output, "\n"); len(lines) > 1 { - output = lines[1] - } + output = parseWindowsRegValue(output) } attrs["cpu.brand"] = strings.TrimSpace(output) } @@ -144,6 +138,21 @@ func machineAttributes(d diag) map[string]interface{} { return machineAttrs } +// parseWindowsRegValue extracts the value from `reg query` output: +// +// HKEY_LOCAL_MACHINE\... +// ValueName REG_SZ the value, possibly with spaces +func parseWindowsRegValue(output string) string { + if idx := strings.Index(output, "REG_SZ"); idx >= 0 { + value := output[idx+len("REG_SZ"):] + // Keep only the first line after the type column. + value, _, _ = strings.Cut(strings.TrimLeft(value, " \t"), "\r") + value, _, _ = strings.Cut(value, "\n") + return strings.TrimSpace(value) + } + return strings.TrimSpace(output) +} + // execCommand runs command[0] with the remaining arguments and returns its // stdout, or "" on any failure. A nil/empty command returns "". func execCommand(command []string, d diag) string { diff --git a/attributes_test.go b/attributes_test.go index 1272111..bdae1b1 100644 --- a/attributes_test.go +++ b/attributes_test.go @@ -100,6 +100,22 @@ func TestUnwrapErrorChainTypes(t *testing.T) { } } +func TestParseWindowsRegValue(t *testing.T) { + guidOut := "\r\nHKEY_LOCAL_MACHINE\\Software\\Microsoft\\Cryptography\r\n" + + " MachineGuid REG_SZ 12345678-abcd-ef00-1122-334455667788\r\n\r\n" + if got := parseWindowsRegValue(guidOut); got != "12345678-abcd-ef00-1122-334455667788" { + t.Errorf("guid parse = %q", got) + } + cpuOut := "\r\nHKEY_LOCAL_MACHINE\\HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0\r\n" + + " ProcessorNameString REG_SZ Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz\r\n\r\n" + if got := parseWindowsRegValue(cpuOut); got != "Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz" { + t.Errorf("cpu parse = %q (spaces must survive)", got) + } + if got := parseWindowsRegValue("no reg marker"); got != "no reg marker" { + t.Errorf("fallback = %q", got) + } +} + func TestBreadcrumbRingDisabled(t *testing.T) { var r *breadcrumbRing // negative MaxBreadcrumbs => nil ring r.add(Breadcrumb{Message: "ignored"}) From 7758d6dde2a92d6826bec86e85a77ace227284ac Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 11:51:24 -0400 Subject: [PATCH 05/13] test(bcd): cover Trace timeout/kill/start-failure paths and add godoc --- bcd.go | 9 +++- bcd_trace_test.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 bcd_trace_test.go diff --git a/bcd.go b/bcd.go index f079eb0..b20639a 100644 --- a/bcd.go +++ b/bcd.go @@ -36,6 +36,9 @@ type globalState struct { m sync.RWMutex } +// GlobalConfig holds configuration applicable to all tracer invocations. +// UpdateConfig replaces the ENTIRE struct: populate every field (or start +// from the documented defaults) rather than passing a partial literal. type GlobalConfig struct { // If the tracer's timeout expires and the tracer cannot be killed, // generate a run-time panic. @@ -217,6 +220,8 @@ type TraceOptions struct { SpawnedGs *sync.WaitGroup } +// Log is the logging interface used by Tracers for execution status +// messages. type Log interface { // Logs the specified message if the specified log level is enabled. Logf(level LogPriority, format string, v ...interface{}) @@ -226,10 +231,12 @@ type Log interface { SetLogLevel(level LogPriority) } +// LogPriority is a bitmask of tracer log levels. type LogPriority int +// Tracer log levels; combine with bitwise OR, or use LogMax for everything. const ( - LogDebug = 1 << iota + LogDebug LogPriority = 1 << iota LogWarning LogError LogMax = (1 << iota) - 1 diff --git a/bcd_trace_test.go b/bcd_trace_test.go new file mode 100644 index 0000000..a4e5a0c --- /dev/null +++ b/bcd_trace_test.go @@ -0,0 +1,106 @@ +//go:build !windows + +package bt + +import ( + "os/exec" + "strings" + "testing" + "time" +) + +// fakeTracer implements the Tracer interface with a configurable command, +// letting Trace's timeout/kill/start-failure paths run without a real +// ptrace binary. +type fakeTracer struct { + makeCmd func() *exec.Cmd + dto TraceOptions +} + +func (f *fakeTracer) AddOptions(options []string, v ...string) []string { + if options != nil { + return append(options, v...) + } + return nil +} +func (f *fakeTracer) AddKV(options []string, key, val string) []string { return options } +func (f *fakeTracer) AddThreadFilter(options []string, tid int) []string { return options } +func (f *fakeTracer) AddFaultedThread(options []string, tid int) []string { return options } +func (f *fakeTracer) AddCallerGo(options []string, goid int) []string { return options } +func (f *fakeTracer) AddClassifier(options []string, class string) []string { return options } +func (f *fakeTracer) Options() []string { return nil } +func (f *fakeTracer) ClearOptions() {} +func (f *fakeTracer) DefaultTraceOptions() *TraceOptions { return &f.dto } +func (f *fakeTracer) Finalize(options []string) *exec.Cmd { return f.makeCmd() } +func (f *fakeTracer) Logf(level LogPriority, format string, v ...interface{}) {} +func (f *fakeTracer) SetLogLevel(level LogPriority) {} +func (f *fakeTracer) String() string { return "fakeTracer" } +func (f *fakeTracer) PutOnTrace() bool { return false } +func (f *fakeTracer) Put(snapshot []byte) error { return nil } + +// traceTestConfig makes trace tests fast and panic-free; the returned func +// restores the documented defaults. +func traceTestConfig() func() { + UpdateConfig(GlobalConfig{ + PanicOnKillFailure: false, + ResendSignal: true, + RateLimit: time.Millisecond, + SynchronousPut: true, + }) + return func() { + UpdateConfig(GlobalConfig{ + PanicOnKillFailure: true, + ResendSignal: true, + RateLimit: time.Second * 3, + SynchronousPut: true, + }) + } +} + +func TestTraceTimeoutKillsProcessWithoutPanic(t *testing.T) { + defer traceTestConfig()() + + tr := &fakeTracer{ + makeCmd: func() *exec.Cmd { return exec.Command("sleep", "60") }, + dto: TraceOptions{Timeout: 5 * time.Second}, + } + + start := time.Now() + err := Trace(tr, nil, &TraceOptions{Timeout: 100 * time.Millisecond}) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("err = %v, want timeout error", err) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Errorf("timeout took %v; kill did not happen promptly", elapsed) + } +} + +func TestTraceStartFailureIsReportedNotPanicked(t *testing.T) { + defer traceTestConfig()() + + tr := &fakeTracer{ + makeCmd: func() *exec.Cmd { return exec.Command("/nonexistent/tracer-binary") }, + dto: TraceOptions{Timeout: 5 * time.Second}, + } + + // The historical bug: the timeout path called tracer.Process.Kill() + // while Process was still nil -> nil-pointer panic. A very short + // timeout races the start failure on purpose. + err := Trace(tr, nil, &TraceOptions{Timeout: time.Millisecond}) + if err == nil { + t.Fatal("expected an error from a tracer that cannot start") + } +} + +func TestTraceSuccess(t *testing.T) { + defer traceTestConfig()() + + tr := &fakeTracer{ + makeCmd: func() *exec.Cmd { return exec.Command("true") }, + dto: TraceOptions{Timeout: 5 * time.Second}, + } + + if err := Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}); err != nil { + t.Fatalf("Trace = %v, want nil", err) + } +} From 9846721e334aa549500d8645ef5d1ea46f2f834c Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 11:53:29 -0400 Subject: [PATCH 06/13] docs: update README and correct godoc --- README.md | 12 ++++++------ logger.go | 5 +++-- report.go | 11 +++++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dcaa9c0..20ad3da 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ func main() { Two endpoint forms are supported: -- `https://submit.backtrace.io/{universe}/{token}/json` | `Endpoint` only | -- `https://{universe}.sp.backtrace.io` | `Endpoint` + `Token` | +- `https://submit.backtrace.io/{universe}/{token}/json` — set `Endpoint` only +- `https://{universe}.sp.backtrace.io` — set `Endpoint` + `Token` `BACKTRACE_ENDPOINT` and `BACKTRACE_TOKEN` environment variables are used as fallbacks when the corresponding fields are empty. @@ -79,7 +79,7 @@ client, err := bt.NewClient(bt.Config{ Attributes: map[string]interface{}{ // stamped on every report "application.environment": "production", }, - AttachmentPaths: []string{"/var/log/app.log"}, // uploaded with every report + AttachmentPaths: []string{"/var/log/app.log"}, // uploaded with every report (10 MiB/file cap) SendEnvVars: true, // env vars as annotation, secrets redacted SampleRate: 1.0, // fraction of reports sent (0 == 1.0) BeforeSend: func(r *bt.ReportData) *bt.ReportData { @@ -102,7 +102,7 @@ client.AddBreadcrumb(bt.Breadcrumb{ }) ``` -Every report automatically includes: hostname, process ID and age, Go version, goroutine count, heap statistics, GC count, CPU architecture and model, OS version, machine GUID, `application.version` / `vcs.revision` (from Go build info), the Go module dependency list, and — on Linux —`/proc` memory and scheduler attributes. +Every report automatically includes: hostname, process ID and age, Go version, goroutine count, heap statistics, GC count, CPU architecture and model, OS version, machine GUID, `application.version` / `vcs.revision` (from Go build info), the Go module dependency list, and — on Linux — `/proc` memory and scheduler attributes. ### net/http middleware @@ -143,7 +143,7 @@ Notes: - Configure `bt.Options` before the first report. For attribute changes at runtime use `bt.SetAttribute` / `bt.SetAttributes`, which are safe for concurrent use. - `bt.FinishSendingReports()` now waits for queued reports **without** stopping the reporter (historically it killed the sender permanently): prefer `bt.Flush(timeout)`. - Source capture now defaults to context lines around each frame instead of whole files: opt back in with `Options.SourceCode = bt.SourceCodeFile`. -- The SDK never panics. `DebugBacktrace` only controls diagnostic logging. +- The reporting API (`Client` methods, `bt.Report`, `bt.ReportPanic`, ...) never panics; `DebugBacktrace` only controls diagnostic logging. (The bcd tracing integration may panic on tracer kill failure unless `GlobalConfig.PanicOnKillFailure` is disabled via `bt.UpdateConfig`.) ## Thread-safety contract @@ -152,7 +152,7 @@ Notes: `Options` struct and `Config` maps are read when reports are captured; mutate them only before reporting starts (or via `SetAttribute`). -# bcd (out-of-process tracing) +## bcd (out-of-process tracing) The `bt` package also provides integration with out-of-process tracers. Using the provided `Tracer` interface, applications may invoke tracer execution on demand: panic and signal handling integrations are provided. diff --git a/logger.go b/logger.go index ae0487d..4e2bd4f 100644 --- a/logger.go +++ b/logger.go @@ -15,8 +15,9 @@ type Logger interface { var defaultDiagLogger Logger = log.New(os.Stderr, "[backtrace] ", log.LstdFlags) // diag is an internal logging helper. Diagnostics are emitted only when -// debug mode is enabled; the SDK never panics and never writes to -// stdout/stderr unless debugging was requested. +// debug mode is enabled; the reporting API never panics and never writes to +// stdout/stderr unless debugging was requested. (The bcd tracing +// integration has its own logging and panic semantics; see GlobalConfig.) type diag struct { logger Logger debug bool diff --git a/report.go b/report.go index b258774..db80727 100644 --- a/report.go +++ b/report.go @@ -19,8 +19,10 @@ type ReportData struct { // Timestamp is the report time in Unix seconds. Timestamp int64 - // Classifiers group the report in the Backtrace UI ("error", "panic", - // "message", plus error-chain type names). + // Classifiers group the report in the Backtrace UI; the SDK sets + // exactly one of "error", "panic", or "message". Error-chain type + // names are reported via the "error.type" attribute and the + // "Error Chain" annotation, not as classifiers. Classifiers []string // Attributes are indexed key/value pairs used for search and @@ -87,8 +89,9 @@ type errorChainLink struct { } // unwrapErrorChain walks err's Unwrap chain (up to maxDepth links) and -// returns the chain description plus the Go type names encountered, for use -// as classifiers. A maxDepth < 0 disables chain capture. +// returns the chain description (Go type name plus message per link), used +// for the "error.type" attribute and the "Error Chain" annotation. +// A maxDepth < 0 disables chain capture. func unwrapErrorChain(err error, maxDepth int) []errorChainLink { if err == nil || maxDepth < 0 { return nil From e943ec73d758a4238569849bac10bd6fb80d857a Mon Sep 17 00:00:00 2001 From: melekr Date: Tue, 4 Aug 2026 12:26:38 -0400 Subject: [PATCH 07/13] fix: Add darwin Trace guard, broader secret scrubbing, hardening - Trace() fails gracefully when a stub tracer (macOS) returns no command instead of crashing the process in the exec goroutine - Env-var redaction broadened (PASS/KEY/DSN/COOKIE/SESSION/SIGNATURE/ BEARER/CONN) plus value shape detection of URL-embedded credentials - Retry-After capped at 5 minutes; token redaction extended to aliased submit-shaped URLs and NewClient error messages - All Client methods are safe no-ops on a nil receiver, as documented --- attributes.go | 22 +++++++++++++++---- attributes_test.go | 31 ++++++++++++++++++++++++++ bcd.go | 7 ++++++ bcd_trace_test.go | 21 ++++++++++++++++++ client.go | 32 ++++++++++++++++++++++++--- client_test.go | 20 +++++++++++++++++ config.go | 20 +++++++++++------ main.go | 4 ++-- main_test.go | 10 +++++++-- transport.go | 54 ++++++++++++++++++++++++++++++++++++---------- transport_test.go | 19 +++++++++++++++- 11 files changed, 211 insertions(+), 29 deletions(-) diff --git a/attributes.go b/attributes.go index 2955886..97ff03c 100644 --- a/attributes.go +++ b/attributes.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "runtime/debug" "strings" @@ -214,16 +215,26 @@ func buildInfoAttributes() (map[string]interface{}, []string) { } // defaultEnvScrubPatterns match environment variable names whose values are -// redacted before submission. Case-insensitive substring match. +// redacted before submission. Case-insensitive substring match. For a crash +// reporter over-redaction beats leaking, so the patterns are deliberately +// broad: PASS covers PASSWORD/PASSWD/PASSPHRASE/DB_PASS, KEY covers +// APIKEY/API_KEY/*_KEY, CONN covers CONNECTION_STRING/CONN_STR, DSN covers +// database and telemetry DSNs. var defaultEnvScrubPatterns = []string{ - "TOKEN", "SECRET", "PASSWORD", "PASSWD", "APIKEY", "API_KEY", - "ACCESS_KEY", "SECRET_KEY", "PRIVATE_KEY", "CREDENTIAL", "AUTH", + "TOKEN", "SECRET", "PASS", "KEY", "CREDENTIAL", "AUTH", + "DSN", "COOKIE", "SESSION", "SIGNATURE", "BEARER", "CONN", } +// urlUserinfoPattern matches connection-string values with embedded +// credentials (scheme://user:password@host), regardless of the variable +// name (DATABASE_URL, REDIS_URL, MONGODB_URI, ...). +var urlUserinfoPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9+.-]*://[^/@\s]+:[^/@\s]+@`) + const redactedValue = "[REDACTED]" // getEnvVars returns the process environment with secret-looking values -// redacted. extraPatterns extends the built-in pattern list. +// redacted: variable names matching the scrub patterns, plus any value that +// embeds URL credentials. extraPatterns extends the built-in pattern list. func getEnvVars(extraPatterns []string) map[string]string { patterns := make([]string, 0, len(defaultEnvScrubPatterns)+len(extraPatterns)) patterns = append(patterns, defaultEnvScrubPatterns...) @@ -242,6 +253,9 @@ func getEnvVars(extraPatterns []string) map[string]string { break } } + if value != redactedValue && urlUserinfoPattern.MatchString(value) { + value = redactedValue + } result[key] = value } return result diff --git a/attributes_test.go b/attributes_test.go index bdae1b1..649e197 100644 --- a/attributes_test.go +++ b/attributes_test.go @@ -28,6 +28,37 @@ func TestGetEnvVarsSplitAndScrub(t *testing.T) { } } +// TestGetEnvVarsScrubsCommonSecretShapes covers the broadened name patterns +// and the value-shape detection of URL-embedded credentials. +func TestGetEnvVarsScrubsCommonSecretShapes(t *testing.T) { + redactedNames := []string{ + "BT_SHAPE_ENCRYPTION_KEY", "BT_SHAPE_SIGNING_KEY", "BT_SHAPE_DB_PASS", + "BT_SHAPE_PASSPHRASE", "BT_SHAPE_SENTRY_DSN", "BT_SHAPE_SESSION_COOKIE", + "BT_SHAPE_CONNECTION_STRING", "BT_SHAPE_BEARER_HEADER", + } + for _, name := range redactedNames { + t.Setenv(name, "sensitive") + } + // Connection strings with embedded credentials are caught by value + // shape, regardless of the variable name. + t.Setenv("BT_SHAPE_DATABASE_URL", "postgres://user:hunter2@db/prod?sslmode=require") + // Plain URLs without credentials survive. + t.Setenv("BT_SHAPE_HOMEPAGE", "https://example.com/path") + + env := getEnvVars(nil) + for _, name := range redactedNames { + if env[name] != redactedValue { + t.Errorf("%s not redacted: %q", name, env[name]) + } + } + if env["BT_SHAPE_DATABASE_URL"] != redactedValue { + t.Errorf("URL-embedded credentials not redacted: %q", env["BT_SHAPE_DATABASE_URL"]) + } + if env["BT_SHAPE_HOMEPAGE"] != "https://example.com/path" { + t.Errorf("credential-free URL over-redacted: %q", env["BT_SHAPE_HOMEPAGE"]) + } +} + func TestStaticAttributes(t *testing.T) { attrs := staticAttributes() for _, key := range []string{ diff --git a/bcd.go b/bcd.go index b20639a..d1e587a 100644 --- a/bcd.go +++ b/bcd.go @@ -475,6 +475,13 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { done := make(chan tracerResult, 1) started := make(chan struct{}) tracer := t.Finalize(options) + if tracer == nil { + // Stub tracers (e.g. on macOS) have no command to run; fail + // gracefully instead of dereferencing nil in the goroutine. + err = errors.New("tracer unavailable on this platform") + t.Logf(LogWarning, "%v\n", err) + return + } if traceOptions.SpawnedGs != nil { traceOptions.SpawnedGs.Add(1) diff --git a/bcd_trace_test.go b/bcd_trace_test.go index a4e5a0c..666b1db 100644 --- a/bcd_trace_test.go +++ b/bcd_trace_test.go @@ -92,6 +92,27 @@ func TestTraceStartFailureIsReportedNotPanicked(t *testing.T) { } } +// TestTraceNilFinalizeFailsGracefully pins the darwin-stub path: a Tracer +// whose Finalize returns nil (no command to run) must produce an error, not +// a nil-pointer crash in the exec goroutine. +func TestTraceNilFinalizeFailsGracefully(t *testing.T) { + defer traceTestConfig()() + + tr := &fakeTracer{ + makeCmd: func() *exec.Cmd { return nil }, + dto: TraceOptions{Timeout: 5 * time.Second}, + } + + err := Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}) + if err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("err = %v, want 'tracer unavailable' error", err) + } + // The trace lock must have been released: a second call still works. + if err := Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}); err == nil { + t.Fatal("second Trace unexpectedly succeeded with nil Finalize") + } +} + func TestTraceSuccess(t *testing.T) { defer traceTestConfig()() diff --git a/client.go b/client.go index 06917fe..2d96c14 100644 --- a/client.go +++ b/client.go @@ -12,7 +12,9 @@ import ( // Client is an instance-based Backtrace reporter. Multiple independent // clients may coexist in one process. All methods are safe for concurrent -// use, never block the caller on network I/O, and never panic. +// use, never block the caller on network I/O, and never panic — including +// on a nil *Client (e.g. when a NewClient error was ignored), where every +// method is a no-op. // // Reports are queued to a background worker; when the queue is full new // reports are dropped and counted (see DroppedReports) instead of blocking. @@ -91,6 +93,9 @@ func (c *Client) diag() diag { // (reported as a message). A nil object is ignored. extraAttributes are // added to this report only; the map is not retained or mutated. func (c *Client) Report(object interface{}, extraAttributes map[string]interface{}) { + if c == nil { + return + } switch v := object.(type) { case nil: return @@ -103,7 +108,7 @@ func (c *Client) Report(object interface{}, extraAttributes map[string]interface // ReportError sends a report for err, capturing its type and unwrap chain. func (c *Client) ReportError(err error, extraAttributes map[string]interface{}) { - if err == nil { + if c == nil || err == nil { return } c.capture(captureInput{ @@ -117,6 +122,9 @@ func (c *Client) ReportError(err error, extraAttributes map[string]interface{}) // ReportMessage sends a plain message report. func (c *Client) ReportMessage(msg string, extraAttributes map[string]interface{}) { + if c == nil { + return + } c.capture(captureInput{ message: msg, classifier: "message", @@ -134,7 +142,7 @@ func (c *Client) ReportMessage(msg string, extraAttributes map[string]interface{ // DefaultFlushTimeout before being dropped: it is likely the process's // last report. func (c *Client) ReportPanicValue(value interface{}, extraAttributes map[string]interface{}) { - if value == nil { + if c == nil || value == nil { return } in := captureInput{ @@ -153,6 +161,9 @@ func (c *Client) ReportPanicValue(value interface{}, extraAttributes map[string] // SetAttribute sets a client-wide attribute included in every subsequent // report. Safe for concurrent use. func (c *Client) SetAttribute(key string, value interface{}) { + if c == nil { + return + } c.amu.Lock() defer c.amu.Unlock() c.attributes[key] = value @@ -160,6 +171,9 @@ func (c *Client) SetAttribute(key string, value interface{}) { // SetAttributes sets multiple client-wide attributes atomically. func (c *Client) SetAttributes(attrs map[string]interface{}) { + if c == nil { + return + } c.amu.Lock() defer c.amu.Unlock() for k, v := range attrs { @@ -170,12 +184,18 @@ func (c *Client) SetAttributes(attrs map[string]interface{}) { // AddBreadcrumb records a breadcrumb attached to every subsequent report as // part of the "breadcrumbs" annotation. Safe for concurrent use. func (c *Client) AddBreadcrumb(b Breadcrumb) { + if c == nil { + return + } c.crumbs.add(b) } // DroppedReports returns the number of reports dropped because the queue was // full, the client was closed, or delivery failed. func (c *Client) DroppedReports() uint64 { + if c == nil { + return 0 + } return c.dropped.Load() } @@ -188,6 +208,9 @@ const flushPollInterval = 10 * time.Millisecond // completed in time. Unlike the legacy FinishSendingReports, Flush never // stops the worker: the client remains fully usable afterwards. func (c *Client) Flush(timeout time.Duration) bool { + if c == nil { + return true + } marker := make(chan struct{}) timer := time.NewTimer(timeout) defer timer.Stop() @@ -237,6 +260,9 @@ func (c *Client) Flush(timeout time.Duration) bool { // Close and Flush must not be called from inside a BeforeSend hook: the // hook runs on the worker goroutine those calls wait on. func (c *Client) Close() { + if c == nil { + return + } c.qmu.Lock() if !c.closed { c.closed = true diff --git a/client_test.go b/client_test.go index 23401d3..7fbe783 100644 --- a/client_test.go +++ b/client_test.go @@ -578,6 +578,26 @@ func TestAttachmentsMultipartSubmission(t *testing.T) { } } +// TestNilClientIsSafe pins the documented contract: every method on a nil +// *Client (ignored NewClient error) is a safe no-op. +func TestNilClientIsSafe(t *testing.T) { + var c *Client + c.Report(errors.New("ignored"), nil) + c.ReportError(errors.New("ignored"), nil) + c.ReportMessage("ignored", nil) + c.ReportPanicValue("ignored", nil) + c.SetAttribute("k", "v") + c.SetAttributes(map[string]interface{}{"k": "v"}) + c.AddBreadcrumb(Breadcrumb{Message: "ignored"}) + if got := c.DroppedReports(); got != 0 { + t.Errorf("DroppedReports on nil = %d", got) + } + if !c.Flush(time.Millisecond) { + t.Error("Flush on nil client should trivially succeed") + } + c.Close() +} + func TestUUID4Format(t *testing.T) { seen := map[string]bool{} for i := 0; i < 1000; i++ { diff --git a/config.go b/config.go index 72a4a0f..4c26ab4 100644 --- a/config.go +++ b/config.go @@ -52,8 +52,10 @@ const ( // DefaultTabWidth is reported to the Backtrace UI for source rendering. DefaultTabWidth = 8 - // DefaultFlushTimeout is used by panic handlers and the legacy - // FinishSendingReports to bound how long delivery is awaited. + // DefaultFlushTimeout bounds how long panic handlers (ReportPanic, + // ReportAndRecoverPanic) wait for delivery before re-panicking or + // returning, and how long a panic report retries a full queue. + // (FinishSendingReports uses the larger DefaultTimeout.) DefaultFlushTimeout = 5 * time.Second ) @@ -98,8 +100,12 @@ type Config struct { Attributes map[string]interface{} // SendEnvVars attaches the process environment to every report as an - // annotation. Values of variables whose names look secret-bearing - // (TOKEN, SECRET, PASSWORD, KEY, ...) are redacted; see ScrubEnvVars. + // annotation. Values are redacted when the variable name contains + // any of: TOKEN, SECRET, PASS, KEY, CREDENTIAL, AUTH, DSN, COOKIE, + // SESSION, SIGNATURE, BEARER, CONN (case-insensitive), or when the + // value embeds URL credentials (scheme://user:pass@...). Extend the + // list with ScrubEnvVars; use BeforeSend for anything beyond + // name/shape matching. SendEnvVars bool // ScrubEnvVars adds case-insensitive substrings to the built-in list @@ -208,10 +214,12 @@ func (c Config) validate() error { return fmt.Errorf("bt: invalid Config.Endpoint: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("bt: Config.Endpoint must be an http(s) URL, got %q", c.Endpoint) + // Redact the endpoint in errors: it may embed a token, and + // NewClient errors flow into user logs. + return fmt.Errorf("bt: Config.Endpoint must be an http(s) URL, got %q", redactURL(c.Endpoint)) } if c.Token != "" && u.RawQuery != "" { - return fmt.Errorf("bt: Config.Endpoint must not carry a query string when Token is set, got %q", c.Endpoint) + return fmt.Errorf("bt: Config.Endpoint must not carry a query string when Token is set, got %q", redactURL(c.Endpoint)) } return nil } diff --git a/main.go b/main.go index 56d2744..2eb3113 100644 --- a/main.go +++ b/main.go @@ -41,8 +41,8 @@ type OptionsStruct struct { // Token is the project token for legacy endpoints; see Config.Token. Token string - // SendEnvVars attaches the process environment (secret-looking values - // redacted) to every report. Default false. + // SendEnvVars attaches the process environment to every report. + // Default false. See Config.SendEnvVars for the redaction rules. SendEnvVars bool // CaptureAllGoroutines includes every goroutine's stack in reports. diff --git a/main_test.go b/main_test.go index 0a4679c..f78d0e2 100644 --- a/main_test.go +++ b/main_test.go @@ -315,6 +315,9 @@ func TestSetAttributeIsConcurrencySafe(t *testing.T) { func TestLegacyEnvVarAnnotations(t *testing.T) { legacyServer.reset() t.Setenv("BT_TEST_SECRET_TOKEN", "hunter2") + // Multi-'=' value without credentials: must survive intact. + t.Setenv("BT_TEST_JAVA_OPTS", "-Da=b -Dc=d") + // Connection string with embedded credentials: redacted by value shape. t.Setenv("BT_TEST_DATABASE_URL", "postgres://u:p@h/db?sslmode=require") Options.SendEnvVars = true @@ -331,7 +334,10 @@ func TestLegacyEnvVarAnnotations(t *testing.T) { if env["BT_TEST_SECRET_TOKEN"] != redactedValue { t.Errorf("secret env var not redacted: %v", env["BT_TEST_SECRET_TOKEN"]) } - if env["BT_TEST_DATABASE_URL"] != "postgres://u:p@h/db?sslmode=require" { - t.Errorf("env value truncated at '=': %v", env["BT_TEST_DATABASE_URL"]) + if env["BT_TEST_JAVA_OPTS"] != "-Da=b -Dc=d" { + t.Errorf("env value truncated at '=': %v", env["BT_TEST_JAVA_OPTS"]) + } + if env["BT_TEST_DATABASE_URL"] != redactedValue { + t.Errorf("connection string with credentials not redacted: %v", env["BT_TEST_DATABASE_URL"]) } } diff --git a/transport.go b/transport.go index 5145b1e..65f05a8 100644 --- a/transport.go +++ b/transport.go @@ -10,6 +10,7 @@ import ( neturl "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -24,6 +25,11 @@ var errRateLimited = errors.New("bt: rate limited by server, report dropped") // Retry-After header. const rateLimitFallback = time.Minute +// rateLimitMaxPause caps server-supplied Retry-After values so one +// malformed or hostile response cannot disable reporting for the process +// lifetime. +const rateLimitMaxPause = 5 * time.Minute + // httpTransport delivers serialized reports over HTTP. It is safe for // concurrent use, applies the configured timeout, verifies response status, // honors 429 Retry-After, and drains response bodies so connections are @@ -230,33 +236,59 @@ func redactURL(u string) string { q.Set("token", "REDACTED") parsed.RawQuery = q.Encode() } - if strings.EqualFold(parsed.Hostname(), "submit.backtrace.io") { - segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") - // The token is always the second path segment - // ({universe}/{token}[/{format}]). - if len(segments) >= 2 { - segments[1] = "REDACTED" - parsed.Path = "/" + strings.Join(segments, "/") - } + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if pathEmbedsToken(parsed.Hostname(), segments) { + segments[1] = "REDACTED" + parsed.Path = "/" + strings.Join(segments, "/") } return parsed.String() } +// submissionFormats are the final path segments of submit-style URLs +// ({universe}/{token}/{format}). +var submissionFormats = map[string]bool{"json": true, "minidump": true, "plcrash": true, "dmp": true} + +var hexTokenPattern = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) + +// pathEmbedsToken reports whether segments[1] is a submission token: always +// for submit.backtrace.io, and for self-hosted/aliased gateways when the +// path has the {universe}/{token}[/{format}] shape (known format suffix or +// a hex token). Plain API paths like /api/post never match. +func pathEmbedsToken(host string, segments []string) bool { + if len(segments) < 2 || len(segments) > 3 { + return false + } + if strings.EqualFold(host, "submit.backtrace.io") { + return true + } + return submissionFormats[strings.ToLower(segments[len(segments)-1])] || + hexTokenPattern.MatchString(segments[1]) +} + // retryAfter parses a Retry-After header given either as delay seconds or as -// an HTTP date, falling back to rateLimitFallback. +// an HTTP date, falling back to rateLimitFallback and capped at +// rateLimitMaxPause. func retryAfter(resp *http.Response) time.Duration { header := resp.Header.Get("Retry-After") if header == "" { return rateLimitFallback } if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 { + if seconds > int(rateLimitMaxPause/time.Second) { + return rateLimitMaxPause + } return time.Duration(seconds) * time.Second } if at, err := http.ParseTime(header); err == nil { - if d := time.Until(at); d > 0 { + d := time.Until(at) + switch { + case d <= 0: + return 0 + case d > rateLimitMaxPause: + return rateLimitMaxPause + default: return d } - return 0 } return rateLimitFallback } diff --git a/transport_test.go b/transport_test.go index 62e7982..0635946 100644 --- a/transport_test.go +++ b/transport_test.go @@ -33,6 +33,14 @@ func TestRetryAfterParsing(t *testing.T) { if d := retryAfter(mk(past)); d != 0 { t.Errorf("past http-date = %v, want 0", d) } + // A hostile/malformed Retry-After must not disable reporting forever. + if d := retryAfter(mk("2147483647")); d != rateLimitMaxPause { + t.Errorf("huge seconds = %v, want cap %v", d, rateLimitMaxPause) + } + farFuture := time.Now().Add(24 * time.Hour * 365).UTC().Format(http.TimeFormat) + if d := retryAfter(mk(farFuture)); d != rateLimitMaxPause { + t.Errorf("far-future date = %v, want cap %v", d, rateLimitMaxPause) + } } func TestTransportPause(t *testing.T) { @@ -66,7 +74,16 @@ func TestRedactURL(t *testing.T) { if got := redactURL("https://submit.backtrace.io/universe/secret-token"); got != "https://submit.backtrace.io/universe/REDACTED" { t.Errorf("2-segment submit path token not redacted: %q", got) } - // Other tokenless URLs pass through unchanged. + // Self-hosted/aliased gateways with the submit path shape: format suffix. + if got := redactURL("https://errors.mycorp.com/universe/sometoken/json"); got != "https://errors.mycorp.com/universe/REDACTED/json" { + t.Errorf("aliased submit path (format suffix) not redacted: %q", got) + } + // ... or a hex-shaped token. + hexTok := "51cc8e69c5b62fa8c72dc963e730f1e8" + if got := redactURL("https://errors.mycorp.com/universe/" + hexTok); got != "https://errors.mycorp.com/universe/REDACTED" { + t.Errorf("aliased submit path (hex token) not redacted: %q", got) + } + // Plain API paths never match the token shape. if got := redactURL("https://uni.sp.backtrace.io/api/post"); got != "https://uni.sp.backtrace.io/api/post" { t.Errorf("tokenless URL modified: %q", got) } From 06a87a2e7891751c6a8bb558b6e80e3165ce0973 Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 7 Aug 2026 17:15:42 -0400 Subject: [PATCH 08/13] fix: core production hardening panic containment, bounded lifecycle, privacy budgets --- attributes.go | 147 ++++------- attributes_darwin.go | 57 +++++ attributes_freebsd.go | 34 +++ attributes_linux.go | 43 ++++ attributes_other.go | 10 + attributes_test.go | 38 +-- attributes_unixfiles.go | 43 ++++ attributes_windows.go | 50 ++++ breadcrumbs.go | 9 +- client.go | 522 +++++++++++++++++++++++++++------------- client_test.go | 380 ++++++++++++++++++++++++++++- config.go | 269 +++++++++++++++++---- config_test.go | 111 ++++++++- errors.go | 8 + fuzz_test.go | 106 ++++++++ logger.go | 21 ++ main.go | 143 ++++++++--- main_test.go | 49 ++++ report.go | 111 +++++++-- safety.go | 53 ++++ threads.go | 138 +++++++++-- threads_test.go | 107 ++++++++ transport.go | 224 ++++++++++++----- transport_test.go | 49 +++- 24 files changed, 2233 insertions(+), 489 deletions(-) create mode 100644 attributes_darwin.go create mode 100644 attributes_freebsd.go create mode 100644 attributes_linux.go create mode 100644 attributes_other.go create mode 100644 attributes_unixfiles.go create mode 100644 attributes_windows.go create mode 100644 errors.go create mode 100644 fuzz_test.go create mode 100644 safety.go diff --git a/attributes.go b/attributes.go index 97ff03c..7b12d16 100644 --- a/attributes.go +++ b/attributes.go @@ -1,9 +1,8 @@ package bt import ( - "context" + neturl "net/url" "os" - "os/exec" "path/filepath" "regexp" "runtime" @@ -13,27 +12,6 @@ import ( "time" ) -// execCommandTimeout bounds every machine-metadata subprocess. -const execCommandTimeout = 2 * time.Second - -var ( - windowsGUIDCommand = []string{"reg", "query", `HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography`, "/v", "MachineGuid"} - linuxGUIDCommand = []string{"sh", "-c", "( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :"} - freebsdGUIDCommand = []string{"sh", "-c", "kenv -q smbios.system.uuid || sysctl -n kern.hostuuid"} - darwinGUIDCommand = []string{"sh", "-c", "ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID | awk -F'= \"' '{print $2}' | tr -d '\"' | tr -d '\n'"} - - // reg query instead of wmic: wmic is removed from Windows 11 24H2 / - // Server 2025. - windowsCPUCommand = []string{"reg", "query", `HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "/v", "ProcessorNameString"} - linuxCPUCommand = []string{"sh", "-c", "lscpu | grep \"Model name\" | awk -F':' '{print $2}' | sed 's/^[[:space:]]*//'"} - darwinCPUCommand = []string{"sh", "-c", "sysctl -n machdep.cpu.brand_string | tr -d '\n'"} - freebsdCPUCommand = []string{"sh", "-c", "sysctl -n hw.model"} - - linuxOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} - darwinOSVersionCommand = []string{"sh", "-c", "sw_vers | grep ProductVersion | awk -F':' '{print $2}' | tr -d '\t' | tr -d '\n'"} - freebsdOSVersionCommand = []string{"sh", "-c", "cat /etc/os-release | grep VERSION= | awk -F'=\"' '{print $2}' | tr -d '\"'"} -) - // processSessionID identifies this process instance across its reports. var processSessionID = uuid4() @@ -87,87 +65,32 @@ func runtimeAttributes(attrs map[string]interface{}) { var ( machineOnce sync.Once machineAttrs map[string]interface{} + + machineGUIDOnce sync.Once + machineGUIDVal string ) -// machineAttributes gathers machine metadata (GUID, CPU model, OS version) -// by shelling out to platform tools. It runs at most once per process, on -// first use — never at import time — and each command is bounded by -// execCommandTimeout. Failures degrade to missing attributes. +// machineAttributes gathers machine metadata (CPU model, OS version) using +// native file and syscall reads — never shell pipelines. It runs at most +// once per process, on first use (never at import time). Failures degrade +// to missing attributes. func machineAttributes(d diag) map[string]interface{} { machineOnce.Do(func() { attrs := map[string]interface{}{} - - var guidCommand, cpuCommand, osCommand []string - switch runtime.GOOS { - case "windows": - guidCommand = windowsGUIDCommand - cpuCommand = windowsCPUCommand - case "linux": - guidCommand = linuxGUIDCommand - cpuCommand = linuxCPUCommand - osCommand = linuxOSVersionCommand - case "darwin": - guidCommand = darwinGUIDCommand - cpuCommand = darwinCPUCommand - osCommand = darwinOSVersionCommand - case "freebsd": - guidCommand = freebsdGUIDCommand - cpuCommand = freebsdCPUCommand - osCommand = freebsdOSVersionCommand - } - - if output := execCommand(guidCommand, d); output != "" { - if runtime.GOOS == "windows" { - output = strings.Trim(parseWindowsRegValue(output), "{}") - } - attrs["guid"] = strings.TrimSpace(output) - } - - if output := execCommand(cpuCommand, d); output != "" { - if runtime.GOOS == "windows" { - output = parseWindowsRegValue(output) - } - attrs["cpu.brand"] = strings.TrimSpace(output) - } - - if output := execCommand(osCommand, d); output != "" { - attrs["uname.version"] = strings.TrimSpace(output) - } - + collectMachineInfo(attrs, d) machineAttrs = attrs }) return machineAttrs } -// parseWindowsRegValue extracts the value from `reg query` output: -// -// HKEY_LOCAL_MACHINE\... -// ValueName REG_SZ the value, possibly with spaces -func parseWindowsRegValue(output string) string { - if idx := strings.Index(output, "REG_SZ"); idx >= 0 { - value := output[idx+len("REG_SZ"):] - // Keep only the first line after the type column. - value, _, _ = strings.Cut(strings.TrimLeft(value, " \t"), "\r") - value, _, _ = strings.Cut(value, "\n") - return strings.TrimSpace(value) - } - return strings.TrimSpace(output) -} - -// execCommand runs command[0] with the remaining arguments and returns its -// stdout, or "" on any failure. A nil/empty command returns "". -func execCommand(command []string, d diag) string { - if len(command) == 0 { - return "" - } - ctx, cancel := context.WithTimeout(context.Background(), execCommandTimeout) - defer cancel() - out, err := exec.CommandContext(ctx, command[0], command[1:]...).Output() - if err != nil { - d.logf("machine attribute command %q failed: %v", command[0], err) - return "" - } - return string(out) +// machineGUID resolves the stable machine identifier lazily and only when a +// client actually opted in via SendMachineID — on macOS this is the one +// probe that spawns a (bounded, non-shell) subprocess. +func machineGUID(d diag) string { + machineGUIDOnce.Do(func() { + machineGUIDVal = collectMachineGUID(d) + }) + return machineGUIDVal } var ( @@ -176,9 +99,12 @@ var ( buildInfoModules []string ) +// maxDependencyModules caps the dependency annotation size. +const maxDependencyModules = 512 + // buildInfoAttributes extracts release metadata embedded by the Go toolchain: // main module version, VCS revision/time/dirty flag, and the dependency list -// (attached to reports as the "Dependencies" annotation). +// (attached to reports as the "Dependencies" annotation, capped). func buildInfoAttributes() (map[string]interface{}, []string) { buildInfoOnce.Do(func() { attrs := map[string]interface{}{} @@ -200,8 +126,12 @@ func buildInfoAttributes() (map[string]interface{}, []string) { attrs["vcs.modified"] = s.Value } } - modules := make([]string, 0, len(info.Deps)) - for _, dep := range info.Deps { + deps := info.Deps + if len(deps) > maxDependencyModules { + deps = deps[:maxDependencyModules] + } + modules := make([]string, 0, len(deps)) + for _, dep := range deps { m := dep if m.Replace != nil { m = m.Replace @@ -256,7 +186,30 @@ func getEnvVars(extraPatterns []string) map[string]string { if value != redactedValue && urlUserinfoPattern.MatchString(value) { value = redactedValue } + // Submission-URL shapes (the SDK's own BACKTRACE_ENDPOINT, or any + // variable holding a tokenized submit URL) get their token + // redacted while keeping the rest of the URL readable. + if value != redactedValue { + value = redactSubmissionValue(value) + } result[key] = value } return result } + +// redactSubmissionValue redacts embedded Backtrace submission tokens +// (?token= query or submit-style path) in URL-shaped env values. +func redactSubmissionValue(value string) string { + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + return value + } + u, err := neturl.Parse(value) + if err != nil { + return value + } + segments := strings.Split(strings.Trim(u.Path, "/"), "/") + if u.Query().Get("token") != "" || pathEmbedsToken(u.Hostname(), segments) { + return redactURL(value) + } + return value +} diff --git a/attributes_darwin.go b/attributes_darwin.go new file mode 100644 index 0000000..c05d5fa --- /dev/null +++ b/attributes_darwin.go @@ -0,0 +1,57 @@ +//go:build darwin + +package bt + +import ( + "context" + "os/exec" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +// machineInfoBudget bounds the one-time ioreg probe. +const machineInfoBudget = time.Second + +// collectMachineInfo gathers macOS machine metadata via sysctl — native +// syscalls only, no subprocesses. +func collectMachineInfo(attrs map[string]interface{}, d diag) { + if v, err := unix.Sysctl("machdep.cpu.brand_string"); err == nil && v != "" { + attrs["cpu.brand"] = v + } + if v, err := unix.Sysctl("kern.osproductversion"); err == nil && v != "" { + attrs["uname.version"] = v + } +} + +// collectMachineGUID resolves the platform UUID (opt-in via +// Config.SendMachineID). This is the only machine probe that spawns a +// subprocess — one bounded ioreg invocation with arguments, never a shell +// pipeline — and it runs only after a client opts in. +func collectMachineGUID(d diag) string { + ctx, cancel := context.WithTimeout(context.Background(), machineInfoBudget) + defer cancel() + out, err := exec.CommandContext(ctx, "ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output() + if err != nil { + d.logf("machine identifier probe (ioreg) failed: %v", err) + return "" + } + return parseIOPlatformUUID(string(out)) +} + +// parseIOPlatformUUID extracts the quoted IOPlatformUUID value from ioreg +// output ("IOPlatformUUID" = "XXXX-..."). +func parseIOPlatformUUID(output string) string { + for _, line := range strings.Split(output, "\n") { + if !strings.Contains(line, "IOPlatformUUID") { + continue + } + _, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + return strings.Trim(strings.TrimSpace(value), `"`) + } + return "" +} diff --git a/attributes_freebsd.go b/attributes_freebsd.go new file mode 100644 index 0000000..0aa63ef --- /dev/null +++ b/attributes_freebsd.go @@ -0,0 +1,34 @@ +//go:build freebsd + +package bt + +import ( + "strings" + + "golang.org/x/sys/unix" +) + +// collectMachineInfo gathers FreeBSD machine metadata via sysctl (native +// syscalls) and /etc/os-release — no subprocesses. +func collectMachineInfo(attrs map[string]interface{}, d diag) { + if v, err := unix.Sysctl("hw.model"); err == nil && v != "" { + attrs["cpu.brand"] = v + } + + if data, err := readSmallFile("/etc/os-release", 16<<10); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if k, v, ok := strings.Cut(line, "="); ok && k == "VERSION" { + attrs["uname.version"] = strings.Trim(strings.TrimSpace(v), `"`) + break + } + } + } +} + +// collectMachineGUID resolves the host UUID (opt-in via Config.SendMachineID). +func collectMachineGUID(d diag) string { + if v, err := unix.Sysctl("kern.hostuuid"); err == nil { + return v + } + return "" +} diff --git a/attributes_linux.go b/attributes_linux.go new file mode 100644 index 0000000..2cc678d --- /dev/null +++ b/attributes_linux.go @@ -0,0 +1,43 @@ +//go:build linux + +package bt + +import "strings" + +// collectMachineInfo gathers Linux machine metadata from procfs and +// standard system files — no subprocesses. +func collectMachineInfo(attrs map[string]interface{}, d diag) { + if data, err := readSmallFile("/proc/cpuinfo", 64<<10); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if key, value, ok := strings.Cut(line, ":"); ok && + strings.TrimSpace(key) == "model name" { + attrs["cpu.brand"] = strings.TrimSpace(value) + break + } + } + } + + if version := osReleaseValue("VERSION"); version != "" { + attrs["uname.version"] = version + } +} + +// collectMachineGUID reads the stable machine identifier (opt-in via +// Config.SendMachineID) from the standard machine-id files. +func collectMachineGUID(d diag) string { + return firstExistingFileLine("/etc/machine-id", "/var/lib/dbus/machine-id") +} + +// osReleaseValue extracts a key from /etc/os-release. +func osReleaseValue(key string) string { + data, err := readSmallFile("/etc/os-release", 16<<10) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + if k, v, ok := strings.Cut(line, "="); ok && k == key { + return strings.Trim(strings.TrimSpace(v), `"`) + } + } + return "" +} diff --git a/attributes_other.go b/attributes_other.go new file mode 100644 index 0000000..41d5465 --- /dev/null +++ b/attributes_other.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin && !freebsd && !windows + +package bt + +// collectMachineInfo has no platform-specific sources here; reports carry +// the portable attributes only. +func collectMachineInfo(attrs map[string]interface{}, d diag) {} + +// collectMachineGUID has no source on this platform. +func collectMachineGUID(d diag) string { return "" } diff --git a/attributes_test.go b/attributes_test.go index 649e197..99572ee 100644 --- a/attributes_test.go +++ b/attributes_test.go @@ -59,6 +59,25 @@ func TestGetEnvVarsScrubsCommonSecretShapes(t *testing.T) { } } +// TestGetEnvVarsRedactsSubmissionURLs pins the fix for the SDK's own +// credential: BACKTRACE_ENDPOINT (or any variable holding a tokenized +// submission URL) must not ship its token in the env annotation. +func TestGetEnvVarsRedactsSubmissionURLs(t *testing.T) { + t.Setenv("BACKTRACE_ENDPOINT", "https://submit.backtrace.io/universe/SECRETSUBMITTOKEN/json") + t.Setenv("BT_SHAPE_LEGACY_ENDPOINT_URL", "https://uni.sp.backtrace.io/post?format=json&token=SECRETQUERYTOKEN") + + env := getEnvVars(nil) + if strings.Contains(env["BACKTRACE_ENDPOINT"], "SECRETSUBMITTOKEN") { + t.Errorf("submit-path token leaked: %q", env["BACKTRACE_ENDPOINT"]) + } + if !strings.Contains(env["BACKTRACE_ENDPOINT"], "submit.backtrace.io") { + t.Errorf("redaction should keep the URL readable: %q", env["BACKTRACE_ENDPOINT"]) + } + if strings.Contains(env["BT_SHAPE_LEGACY_ENDPOINT_URL"], "SECRETQUERYTOKEN") { + t.Errorf("query token leaked: %q", env["BT_SHAPE_LEGACY_ENDPOINT_URL"]) + } +} + func TestStaticAttributes(t *testing.T) { attrs := staticAttributes() for _, key := range []string{ @@ -122,28 +141,15 @@ func TestBuildInfoAttributesDoesNotPanic(t *testing.T) { func TestUnwrapErrorChainTypes(t *testing.T) { err := &testWrapErr{msg: "outer", inner: &testWrapErr{msg: "inner"}} - chain := unwrapErrorChain(err, DefaultMaxErrorDepth) + chain := unwrapErrorChain(err, DefaultMaxErrorDepth, DefaultMaxErrorNodes) if len(chain) != 2 { t.Fatalf("chain length = %d", len(chain)) } if chain[0].Type != "*bt.testWrapErr" || chain[0].Message != "outer" { t.Errorf("chain head = %+v", chain[0]) } -} - -func TestParseWindowsRegValue(t *testing.T) { - guidOut := "\r\nHKEY_LOCAL_MACHINE\\Software\\Microsoft\\Cryptography\r\n" + - " MachineGuid REG_SZ 12345678-abcd-ef00-1122-334455667788\r\n\r\n" - if got := parseWindowsRegValue(guidOut); got != "12345678-abcd-ef00-1122-334455667788" { - t.Errorf("guid parse = %q", got) - } - cpuOut := "\r\nHKEY_LOCAL_MACHINE\\HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0\r\n" + - " ProcessorNameString REG_SZ Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz\r\n\r\n" - if got := parseWindowsRegValue(cpuOut); got != "Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz" { - t.Errorf("cpu parse = %q (spaces must survive)", got) - } - if got := parseWindowsRegValue("no reg marker"); got != "no reg marker" { - t.Errorf("fallback = %q", got) + if chain[1].ParentID == nil || *chain[1].ParentID != 0 || chain[1].Source != "unwrap" { + t.Errorf("chain link parent metadata = %+v", chain[1]) } } diff --git a/attributes_unixfiles.go b/attributes_unixfiles.go new file mode 100644 index 0000000..1313ca1 --- /dev/null +++ b/attributes_unixfiles.go @@ -0,0 +1,43 @@ +//go:build linux || freebsd + +package bt + +import ( + "os" + "strings" +) + +// firstExistingFileLine returns the first non-empty line of the first +// readable file in paths, capped at 4 KiB. +func firstExistingFileLine(paths ...string) string { + for _, p := range paths { + data, err := readSmallFile(p, 4096) + if err != nil { + continue + } + line, _, _ := strings.Cut(strings.TrimSpace(string(data)), "\n") + if line != "" { + return line + } + } + return "" +} + +// readSmallFile reads a regular file bounded by maxBytes. +func readSmallFile(path string, maxBytes int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, os.ErrInvalid + } + buf := make([]byte, maxBytes) + n, _ := f.Read(buf) + return buf[:n], nil +} diff --git a/attributes_windows.go b/attributes_windows.go new file mode 100644 index 0000000..0ced764 --- /dev/null +++ b/attributes_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package bt + +import "golang.org/x/sys/windows/registry" + +// collectMachineInfo gathers Windows machine metadata from the registry — +// native API calls, no subprocesses (wmic is removed from Windows 11 24H2 +// and reg.exe is unnecessary). +func collectMachineInfo(attrs map[string]interface{}, d diag) { + if v := regString(registry.LOCAL_MACHINE, + `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, "ProcessorNameString", d); v != "" { + attrs["cpu.brand"] = v + } + // DisplayVersion exists on 20H2+; ReleaseId covers 1511..2004 (and is + // frozen at "2009" afterwards, hence the ordering). + version := regString(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, "DisplayVersion", d) + if version == "" { + version = regString(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, "ReleaseId", d) + } + if version != "" { + attrs["uname.version"] = version + } +} + +// collectMachineGUID reads the stable machine identifier (opt-in via +// Config.SendMachineID) from the registry. +func collectMachineGUID(d diag) string { + return regString(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Cryptography`, "MachineGuid", d) +} + +func regString(root registry.Key, path, name string, d diag) string { + // WOW64_64KEY: read the 64-bit registry view so 32-bit builds see the + // same MachineGuid/CPU keys as native ones. + key, err := registry.OpenKey(root, path, registry.QUERY_VALUE|registry.WOW64_64KEY) + if err != nil { + d.logf("machine attribute registry open %q failed: %v", path, err) + return "" + } + defer key.Close() + value, _, err := key.GetStringValue(name) + if err != nil { + d.logf("machine attribute registry read %q\\%s failed: %v", path, name, err) + return "" + } + return value +} diff --git a/breadcrumbs.go b/breadcrumbs.go index af16bee..2560a0c 100644 --- a/breadcrumbs.go +++ b/breadcrumbs.go @@ -53,10 +53,17 @@ func newBreadcrumbRing(capacity int) *breadcrumbRing { return &breadcrumbRing{buf: make([]Breadcrumb, capacity)} } +// cloneBreadcrumb detaches the attribute map from caller ownership. +func cloneBreadcrumb(b Breadcrumb) Breadcrumb { + b.Attributes = cloneAnyMap(b.Attributes) + return b +} + func (r *breadcrumbRing) add(b Breadcrumb) { if r == nil { return } + b = cloneBreadcrumb(b) if b.Timestamp == 0 { b.Timestamp = time.Now().UnixMilli() } @@ -93,7 +100,7 @@ func (r *breadcrumbRing) snapshot() []Breadcrumb { } out := make([]Breadcrumb, r.size) for i := 0; i < r.size; i++ { - out[i] = r.buf[(r.head+i)%len(r.buf)] + out[i] = cloneBreadcrumb(r.buf[(r.head+i)%len(r.buf)]) } return out } diff --git a/client.go b/client.go index 2d96c14..3d8af90 100644 --- a/client.go +++ b/client.go @@ -1,8 +1,8 @@ package bt import ( + "context" "encoding/json" - "fmt" "math/rand/v2" "runtime" "sync" @@ -12,56 +12,94 @@ import ( // Client is an instance-based Backtrace reporter. Multiple independent // clients may coexist in one process. All methods are safe for concurrent -// use, never block the caller on network I/O, and never panic — including -// on a nil *Client (e.g. when a NewClient error was ignored), where every -// method is a no-op. +// use and never panic — including on a nil *Client (e.g. when a NewClient +// error was ignored), where every method is a no-op. // -// Reports are queued to a background worker; when the queue is full new -// reports are dropped and counted (see DroppedReports) instead of blocking. -// Call Flush to wait for delivery of queued reports and Close to shut the -// client down. +// Regular Report* calls never block on network I/O or queue admission: +// reports are queued to a background worker, and a full queue drops the +// newest report and counts it (see Stats). Bounded synchronous waiting +// exists only where explicitly documented: Flush/FlushContext, +// Close/CloseContext, and ReportPanicValueAndFlush. type Client struct { // cfgFn returns the normalized configuration. For clients created by // NewClient it returns a fixed config; the legacy global client // re-reads bt.Options so that historical mutate-the-global usage - // keeps working. + // keeps working. Each report snapshots the config at capture time so + // later global changes cannot reroute queued reports. cfgFn func() Config + // internalDiag is fixed at construction and used on recovery paths, + // where re-reading configuration could itself fail. + internalDiag diag + transport *httpTransport - queue chan clientJob - qmu sync.RWMutex // guards closed + sends into queue - closed bool + // ctx is the SDK root context; cancel aborts in-flight requests on + // CloseContext deadline expiry. + ctx context.Context + cancel context.CancelFunc + + queue chan clientJob + enqueueMu sync.Mutex // guards closed, accepted, and queue admission + closed bool + accepted uint64 + processed atomic.Uint64 + + // progress is a broadcast channel: replaced (and the old one closed) + // each time the worker finishes a job or the client closes, waking + // every waiter. + pmu sync.Mutex + progress chan struct{} + + closeOnce sync.Once workerDone chan struct{} + shutdownTimeout time.Duration + amu sync.Mutex // guards attributes attributes map[string]interface{} crumbs *breadcrumbRing - dropped atomic.Uint64 + stats internalStats + + // logBusy backs the diag re-entrancy guard for this client. + logBusy atomic.Int32 } -// clientJob is either a queued report (data != nil) or a flush marker. +// clientJob is a queued report with its admission sequence number. type clientJob struct { - data *queuedReport - flush chan struct{} + seq uint64 + data *queuedReport } -// queuedReport carries everything captured on the caller's goroutine; +// queuedReport carries everything captured on the caller's goroutine, +// including the delivery/scrubbing configuration in effect at capture time; // parsing and I/O happen on the worker. type queuedReport struct { + cfg Config stack []byte attributes map[string]interface{} annotations map[string]interface{} classifiers []string + reportType string // SDK-known type for diagnostics (never user data) timestamp int64 } +// nonBlocking is a pre-canceled context: queue admission under it makes one +// attempt and drops immediately, never waiting. +var nonBlocking = func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +}() + // NewClient creates and starts a reporter with the given configuration. -// It returns an error when the endpoint is missing or unparsable. +// It returns an error when the endpoint is missing or unparsable, or when +// an explicitly set option is invalid. The Config's top-level maps and +// slices are cloned; the caller may reuse them afterwards. func NewClient(cfg Config) (*Client, error) { - n := cfg.normalize() + n := cloneConfig(cfg).normalize() if err := n.validate(); err != nil { return nil, err } @@ -71,27 +109,30 @@ func NewClient(cfg Config) (*Client, error) { // startClient wires up a client around a config source and starts its worker. func startClient(cfgFn func() Config) *Client { cfg := cfgFn() + ctx, cancel := context.WithCancel(context.Background()) c := &Client{ - cfgFn: cfgFn, - transport: newHTTPTransport(cfg.HTTPClient, cfg.Timeout), - queue: make(chan clientJob, cfg.QueueSize), - workerDone: make(chan struct{}), - attributes: map[string]interface{}{}, - crumbs: newBreadcrumbRing(cfg.MaxBreadcrumbs), - } + cfgFn: cfgFn, + transport: newHTTPTransport(cfg.HTTPClient, cfg.Timeout), + ctx: ctx, + cancel: cancel, + queue: make(chan clientJob, cfg.QueueSize), + progress: make(chan struct{}), + workerDone: make(chan struct{}), + shutdownTimeout: cfg.ShutdownTimeout, + attributes: map[string]interface{}{}, + crumbs: newBreadcrumbRing(cfg.MaxBreadcrumbs), + } + // The re-entrancy guard needs the client's address, so wire the + // diagnostics after construction. + c.internalDiag = diag{logger: cfg.Logger, debug: cfg.Debug, busy: &c.logBusy} go c.worker() return c } -func (c *Client) diag() diag { - cfg := c.cfgFn() - return diag{logger: cfg.Logger, debug: cfg.Debug} -} - -// Report sends an error report. object may be an error (its message, -// type and unwrap chain are captured) or any value convertible to a string +// Report sends an error report. object may be an error (its message, type +// and unwrap graph are captured) or any value convertible to a string // (reported as a message). A nil object is ignored. extraAttributes are -// added to this report only; the map is not retained or mutated. +// added to this report only; the map is copied, never retained or mutated. func (c *Client) Report(object interface{}, extraAttributes map[string]interface{}) { if c == nil { return @@ -102,17 +143,17 @@ func (c *Client) Report(object interface{}, extraAttributes map[string]interface case error: c.ReportError(v, extraAttributes) default: - c.ReportMessage(fmt.Sprint(v), extraAttributes) + c.ReportMessage(safeSprint(v), extraAttributes) } } -// ReportError sends a report for err, capturing its type and unwrap chain. +// ReportError sends a report for err, capturing its type and unwrap graph. func (c *Client) ReportError(err error, extraAttributes map[string]interface{}) { if c == nil || err == nil { return } - c.capture(captureInput{ - message: err.Error(), + c.capture(nonBlocking, captureInput{ + message: safeErrorString(err), err: err, classifier: "error", reportType: "error", @@ -125,7 +166,7 @@ func (c *Client) ReportMessage(msg string, extraAttributes map[string]interface{ if c == nil { return } - c.capture(captureInput{ + c.capture(nonBlocking, captureInput{ message: msg, classifier: "message", reportType: "message", @@ -133,29 +174,43 @@ func (c *Client) ReportMessage(msg string, extraAttributes map[string]interface{ }) } -// ReportPanicValue sends a report for a recovered panic value. It does not -// call recover itself and does not re-panic; it is intended for middleware -// and custom panic handlers. Call Flush afterwards when the process (or -// goroutine) is about to die. -// -// Unlike regular reports, a panic report retries a full queue for up to -// DefaultFlushTimeout before being dropped: it is likely the process's -// last report. +// ReportPanicValue sends a report for a recovered panic value without +// blocking: a full queue drops the report (counted in Stats). Use +// ReportPanicValueAndFlush when the goroutine or process is about to die +// and delivery must be awaited. func (c *Client) ReportPanicValue(value interface{}, extraAttributes map[string]interface{}) { if c == nil || value == nil { return } + c.capture(nonBlocking, panicCaptureInput(value, extraAttributes)) +} + +// ReportPanicValueAndFlush captures a recovered panic value and waits for +// its delivery under ONE deadline covering queue admission and flushing. +// It reports whether the report was accepted and processed in time. +func (c *Client) ReportPanicValueAndFlush(value interface{}, extraAttributes map[string]interface{}, timeout time.Duration) bool { + if c == nil || value == nil { + return true + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + seq, accepted := c.capture(ctx, panicCaptureInput(value, extraAttributes)) + return accepted && c.flushTargetContext(ctx, seq) +} + +func panicCaptureInput(value interface{}, extra map[string]interface{}) captureInput { in := captureInput{ - message: fmt.Sprint(value), - classifier: "panic", - reportType: "panic", - extra: extraAttributes, - enqueueWait: DefaultFlushTimeout, + message: safeSprint(value), + classifier: "panic", + reportType: "panic", + extra: extra, } if err, ok := value.(error); ok { in.err = err + in.message = safeErrorString(err) } - c.capture(in) + return in } // SetAttribute sets a client-wide attribute included in every subsequent @@ -169,7 +224,8 @@ func (c *Client) SetAttribute(key string, value interface{}) { c.attributes[key] = value } -// SetAttributes sets multiple client-wide attributes atomically. +// SetAttributes sets multiple client-wide attributes atomically. The map is +// copied. func (c *Client) SetAttributes(attrs map[string]interface{}) { if c == nil { return @@ -181,8 +237,8 @@ func (c *Client) SetAttributes(attrs map[string]interface{}) { } } -// AddBreadcrumb records a breadcrumb attached to every subsequent report as -// part of the "breadcrumbs" annotation. Safe for concurrent use. +// AddBreadcrumb records a breadcrumb attached to every subsequent report. +// The breadcrumb's attribute map is copied. Safe for concurrent use. func (c *Client) AddBreadcrumb(b Breadcrumb) { if c == nil { return @@ -190,86 +246,139 @@ func (c *Client) AddBreadcrumb(b Breadcrumb) { c.crumbs.add(b) } -// DroppedReports returns the number of reports dropped because the queue was -// full, the client was closed, or delivery failed. +// Stats returns an immutable snapshot of the client's report accounting. +func (c *Client) Stats() ClientStats { + if c == nil { + return ClientStats{} + } + return c.stats.snapshot() +} + +// DroppedReports returns the total number of reports discarded for any +// reason (queue full, closed, sampling, BeforeSend, serialization, size +// budgets, rate limiting, network or server failure, internal error). +// See Stats for the per-reason breakdown. func (c *Client) DroppedReports() uint64 { if c == nil { return 0 } - return c.dropped.Load() + return c.stats.droppedTotal() } -// flushPollInterval paces retries when the queue is too full to accept a -// flush marker or a panic report immediately. -const flushPollInterval = 10 * time.Millisecond +// FlushContext blocks until every report accepted BEFORE the call has been +// processed, or until ctx is done. Reports enqueued after the call do not +// extend the wait (strict capture-time barrier). It returns true when the +// pre-call backlog was processed. Flushing proves local processing and +// send completion, not backend acceptance. +func (c *Client) FlushContext(ctx context.Context) bool { + if c == nil { + return true + } + if ctx == nil { + ctx = context.Background() + } + c.enqueueMu.Lock() + target := c.accepted + c.enqueueMu.Unlock() + return c.flushTargetContext(ctx, target) +} -// Flush blocks until all reports queued at the time of the call have been -// processed, or until timeout elapses. It reports whether the drain -// completed in time. Unlike the legacy FinishSendingReports, Flush never -// stops the worker: the client remains fully usable afterwards. +// Flush is FlushContext with a timeout. The client stays fully usable +// afterwards. func (c *Client) Flush(timeout time.Duration) bool { if c == nil { return true } - marker := make(chan struct{}) - timer := time.NewTimer(timeout) - defer timer.Stop() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return c.FlushContext(ctx) +} +// flushTargetContext waits until the worker has processed the report with +// sequence number target. The broadcast channel is grabbed before each +// re-check so an advance signaled in between cannot be lost. +func (c *Client) flushTargetContext(ctx context.Context, target uint64) bool { for { - c.qmu.RLock() - if c.closed { - c.qmu.RUnlock() - // Close drains the queue; wait for the worker to - // finish, bounded by the timeout. - select { - case <-c.workerDone: - return true - case <-timer.C: - return false - } + wait := c.progressCh() + if c.processed.Load() >= target { + return true } - // Non-blocking attempt only: holding qmu across a blocking - // send would stall every Report() caller behind a queued - // Close (RWMutex writer preference). select { - case c.queue <- clientJob{flush: marker}: - c.qmu.RUnlock() - select { - case <-marker: - return true - case <-timer.C: - return false - } - default: - } - c.qmu.RUnlock() - - select { - case <-timer.C: + case <-ctx.Done(): return false - case <-time.After(flushPollInterval): + case <-wait: + case <-c.workerDone: + return c.processed.Load() >= target } } } -// Close drains the queue, stops the worker, and releases the client. -// Subsequent reports are dropped (and counted). Close is idempotent. -// Call Flush first if you need a bounded wait; Close waits for the full -// drain (each send is bounded by the configured timeout). +// progressCh returns the current broadcast generation channel. +func (c *Client) progressCh() <-chan struct{} { + c.pmu.Lock() + ch := c.progress + c.pmu.Unlock() + return ch +} + +// signalProgress wakes every waiter (flushers and blocked panic enqueuers). +func (c *Client) signalProgress() { + c.pmu.Lock() + close(c.progress) + c.progress = make(chan struct{}) + c.pmu.Unlock() +} + +// CloseContext drains the queue, stops the worker, and releases the client. +// If ctx expires first, in-flight and queued submissions are cancelled and +// CloseContext returns false. Subsequent reports are dropped (and counted). +// Safe to call multiple times. // // Close and Flush must not be called from inside a BeforeSend hook: the // hook runs on the worker goroutine those calls wait on. +func (c *Client) CloseContext(ctx context.Context) bool { + if c == nil { + return true + } + if ctx == nil { + ctx = context.Background() + } + c.beginClose() + + select { + case <-c.workerDone: + c.cancel() + c.transport.closeIdleConnections() + return true + case <-ctx.Done(): + // Abort the current request and cause queued requests to fail + // quickly; the worker still exits on its own. + c.cancel() + c.transport.closeIdleConnections() + return false + } +} + +// Close drains the queue and stops the worker, bounded by the configured +// ShutdownTimeout (default 5s). Call Flush first if you need a distinct +// delivery guarantee. Close is idempotent. func (c *Client) Close() { if c == nil { return } - c.qmu.Lock() - if !c.closed { + ctx, cancel := context.WithTimeout(context.Background(), c.shutdownTimeout) + defer cancel() + _ = c.CloseContext(ctx) +} + +func (c *Client) beginClose() { + c.closeOnce.Do(func() { + c.enqueueMu.Lock() c.closed = true close(c.queue) - } - c.qmu.Unlock() - <-c.workerDone + c.enqueueMu.Unlock() + c.signalProgress() + }) } // captureInput bundles the per-call capture parameters. @@ -279,32 +388,38 @@ type captureInput struct { classifier string reportType string extra map[string]interface{} - // enqueueWait bounds how long a full queue is retried before the - // report is dropped; zero means drop immediately (never block). - enqueueWait time.Duration } // capture assembles everything that must be observed on the caller's -// goroutine (stack, attribute snapshot) and enqueues the report. It never -// blocks on the queue and never panics. -func (c *Client) capture(in captureInput) { - defer c.recoverInternal("capture") +// goroutine (stack, attribute snapshot, delivery config) and enqueues the +// report. With a nil ctx it never blocks; with a ctx it retries queue +// admission until the context is done. It never panics. +func (c *Client) capture(ctx context.Context, in captureInput) (seq uint64, accepted bool) { + defer func() { + if r := recover(); r != nil { + // Type-only: the panic value may carry user data. + c.stats.drop(dropInternal) + c.internalDiag.logf("internal error while capturing report (please report to backtrace-labs/backtrace-go): %T", r) + seq, accepted = 0, false + } + }() - cfg := c.cfgFn() + cfg := cloneConfig(c.cfgFn()) if cfg.SampleRate < 1 && rand.Float64() >= cfg.SampleRate { - c.diag().logf("report sampled out (SampleRate=%v)", cfg.SampleRate) - return + c.stats.drop(dropSampled) + c.internalDiag.logf("report sampled out (SampleRate=%v)", cfg.SampleRate) + return 0, false } attributes := map[string]interface{}{} for k, v := range staticAttributes() { attributes[k] = v } - updateAttrsWithProcMemInfo(attributes, c.diag()) + updateAttrsWithProcMemInfo(attributes, c.internalDiag) runtimeAttributes(attributes) - // Config-level attributes (treated as read-only after NewClient). + // Config-level attributes (cloned at capture; safe to iterate). for k, v := range cfg.Attributes { attributes[k] = v } @@ -322,7 +437,7 @@ func (c *Client) capture(in captureInput) { annotations := map[string]interface{}{} if in.err != nil { - chain := unwrapErrorChain(in.err, cfg.MaxErrorDepth) + chain := unwrapErrorChain(in.err, cfg.MaxErrorDepth, cfg.MaxErrorNodes) if len(chain) > 0 { attributes["error.type"] = chain[0].Type annotations["Error Chain"] = chain @@ -342,68 +457,86 @@ func (c *Client) capture(in captureInput) { annotations["breadcrumbs"] = crumbs } - c.enqueue(clientJob{data: &queuedReport{ - stack: captureStack(cfg.CaptureAllGoroutines), + return c.enqueueContext(ctx, clientJob{data: &queuedReport{ + cfg: cfg, + stack: captureStack(cfg.CaptureAllGoroutines, cfg.MaxStackBytes), attributes: attributes, annotations: annotations, classifiers: classifiers, + reportType: in.reportType, timestamp: time.Now().Unix(), - }}, in.enqueueWait) + }}) } -// enqueue queues a job without ever holding qmu across a blocking send. -// With wait <= 0 a full queue drops the report immediately (regular -// reports never block the caller). A positive wait — used for panic -// reports, which are the process's last words — retries for up to that -// duration before dropping. -func (c *Client) enqueue(j clientJob, wait time.Duration) { - deadline := time.Now().Add(wait) +// enqueueContext admits a job to the queue under the enqueue lock, assigning +// its sequence number. A full queue is retried on worker progress until ctx +// is done (the nonBlocking sentinel makes exactly one attempt). The lock is +// never held across a blocking operation. +func (c *Client) enqueueContext(ctx context.Context, j clientJob) (uint64, bool) { + if ctx == nil { + ctx = nonBlocking + } for { - c.qmu.RLock() + // Grab the broadcast channel BEFORE the admission attempt: any + // queue space freed after the attempt comes from a completion + // that closes exactly this channel, so the wake-up cannot be + // lost in between. + wait := c.progressCh() + + c.enqueueMu.Lock() if c.closed { - c.qmu.RUnlock() - c.dropped.Add(1) - c.diag().logf("report dropped: client closed") - return + c.enqueueMu.Unlock() + c.stats.drop(dropClosed) + c.internalDiag.logf("report dropped: client closed") + return 0, false } + next := c.accepted + 1 + j.seq = next select { case c.queue <- j: - c.qmu.RUnlock() - return + c.accepted = next + c.enqueueMu.Unlock() + c.stats.accepted.Add(1) + return next, true default: } - c.qmu.RUnlock() + c.enqueueMu.Unlock() - if wait <= 0 || !time.Now().Before(deadline) { - c.dropped.Add(1) - c.diag().logf("report dropped: queue full (capacity %d)", cap(c.queue)) - return + select { + case <-ctx.Done(): + c.stats.drop(dropQueueFull) + c.internalDiag.logf("report dropped: queue full (capacity %d)", cap(c.queue)) + return 0, false + case <-wait: + case <-c.workerDone: + c.stats.drop(dropClosed) + return 0, false } - time.Sleep(flushPollInterval) } } // worker is the single consumer of the queue. It exits when Close closes the -// queue, after draining remaining jobs. +// queue, after draining remaining jobs. Every processed job broadcasts +// progress to flushers and blocked panic enqueuers. func (c *Client) worker() { defer close(c.workerDone) + defer c.signalProgress() for j := range c.queue { - if j.flush != nil { - close(j.flush) - continue - } c.processAndSend(j.data) + c.processed.Store(j.seq) + c.signalProgress() } } // processAndSend turns a queued report into the wire payload and delivers -// it. Runs on the worker goroutine; all failure modes degrade to a debug log -// plus the dropped counter. +// it, using the configuration snapshot taken at capture time. Runs on the +// worker goroutine; all failure modes degrade to a debug log plus a stats +// counter. func (c *Client) processAndSend(qr *queuedReport) { defer c.recoverInternal("processAndSend") - cfg := c.cfgFn() - d := diag{logger: cfg.Logger, debug: cfg.Debug} + cfg := qr.cfg + d := diag{logger: cfg.Logger, debug: cfg.Debug, busy: &c.logBusy} // Machine and build metadata are gathered lazily (never at import // time) and merged without overriding caller-provided values. @@ -413,6 +546,15 @@ func (c *Client) processAndSend(qr *queuedReport) { qr.attributes[k] = v } } + // The stable machine identifier is opt-in, and its probe only + // runs once someone opts in. + if cfg.SendMachineID { + if guid := machineGUID(d); guid != "" { + if _, exists := qr.attributes["guid"]; !exists { + qr.attributes["guid"] = guid + } + } + } } biAttrs, modules := buildInfoAttributes() for k, v := range biAttrs { @@ -430,6 +572,9 @@ func (c *Client) processAndSend(qr *queuedReport) { mode: cfg.SourceCode, contextLines: cfg.ContextLineCount, tabWidth: cfg.TabWidth, + roots: cfg.SourceRoots, + maxFileBytes: cfg.MaxSourceFileBytes, + maxTotal: cfg.MaxSourceBytes, }) report := &ReportData{ @@ -441,11 +586,18 @@ func (c *Client) processAndSend(qr *queuedReport) { Threads: threads, SourceCode: sourceCode, MainThread: mainThread, - Attachments: append([]string(nil), cfg.AttachmentPaths...), + } + // A negative MaxAttachments disables attachments entirely (documented); + // don't even open the files. + if cfg.MaxAttachments >= 0 { + report.Attachments = append([]string(nil), cfg.AttachmentPaths...) } if cfg.BeforeSend != nil { if modified := c.runBeforeSend(cfg.BeforeSend, report, d); modified == nil { + // Counts both deliberate drops (hook returned nil) and + // panicking hooks (fail closed). + c.stats.drop(dropBeforeSend) d.logf("report %s dropped by BeforeSend", report.UUID) return } else { @@ -455,8 +607,14 @@ func (c *Client) processAndSend(qr *queuedReport) { body, err := json.Marshal(report.toWire()) if err != nil { - c.dropped.Add(1) - d.logf("report %s dropped: marshal failed: %v", report.UUID, err) + c.stats.drop(dropSerialization) + d.logf("report %s dropped: serialization failed (%T)", report.UUID, err) + return + } + if len(body) > cfg.MaxReportBytes { + c.stats.drop(dropOversize) + d.logf("report %s dropped: %d bytes exceeds the %d-byte report limit", + report.UUID, len(body), cfg.MaxReportBytes) return } @@ -471,15 +629,22 @@ func (c *Client) processAndSend(qr *queuedReport) { } } - if cfg.Debug { - pretty, _ := json.MarshalIndent(report.toWire(), "", " ") - d.logf("sending report %s to %s\n%s", report.UUID, redactURL(cfg.submissionURL()), pretty) - } + // Diagnostics are payload-free by design: report contents are never + // logged (debug logging is often enabled during incidents, exactly + // when secrets are most likely to be present). + d.logf("sending report %s (type=%s, json_bytes=%d, attachments=%d, destination=%s)", + report.UUID, qr.reportType, len(body), + len(report.Attachments)+len(inline), redactURL(cfg.submissionURL())) - if err := c.transport.send(cfg.submissionURL(), body, report.Attachments, inline, d); err != nil { - c.dropped.Add(1) + reason, err := c.transport.send(c.ctx, cfg.submissionURL(), body, + report.Attachments, inline, cfg.attachmentLimits(), d) + if err != nil { + c.stats.drop(reason) d.logf("report %s dropped: %v", report.UUID, err) + return } + c.stats.delivered.Add(1) + d.logf("report %s delivered", report.UUID) } // runBeforeSend isolates user hook panics from the worker. A panicking hook @@ -488,8 +653,10 @@ func (c *Client) processAndSend(qr *queuedReport) { func (c *Client) runBeforeSend(hook func(*ReportData) *ReportData, report *ReportData, d diag) (out *ReportData) { defer func() { if r := recover(); r != nil { - c.dropped.Add(1) - d.logf("BeforeSend panicked (%v); dropping report %s", r, report.UUID) + // The caller's nil branch performs the stats accounting. + // Log only the TYPE of the panic value: the hook exists to + // scrub data, so its panic payload may itself be sensitive. + d.logf("BeforeSend panicked (%T); dropping report %s", r, report.UUID) out = nil } }() @@ -497,24 +664,39 @@ func (c *Client) runBeforeSend(hook func(*ReportData) *ReportData, report *Repor } // recoverInternal is the last line of defense: an SDK bug must never crash -// the host application. +// the host application. It uses the fixed construction-time diagnostics and +// never re-enters configuration code. func (c *Client) recoverInternal(where string) { if r := recover(); r != nil { + // Type-only for the panic value: json.Marshal re-panics user + // MarshalJSON panics, whose contents may be sensitive. The stack + // is SDK frames and is needed for bug reports. + c.stats.drop(dropInternal) buf := make([]byte, 4096) n := runtime.Stack(buf, false) - c.diag().logf("internal error in %s (please report to backtrace-labs/backtrace-go): %v\n%s", where, r, buf[:n]) + c.internalDiag.logf("internal error in %s (please report to backtrace-labs/backtrace-go): %T\n%s", where, r, buf[:n]) } } // captureStack returns the formatted stack trace of the calling goroutine, -// or of all goroutines when all is true. -func captureStack(all bool) []byte { - buf := make([]byte, 1024) +// or of all goroutines when all is true, capped at maxBytes. +func captureStack(all bool, maxBytes int) []byte { + if maxBytes < 1024 { + maxBytes = 1024 + } + size := 64 << 10 + if size > maxBytes { + size = maxBytes + } for { + buf := make([]byte, size) n := runtime.Stack(buf, all) - if n < len(buf) { + if n < len(buf) || size == maxBytes { return buf[:n] } - buf = make([]byte, 2*len(buf)) + size *= 2 + if size > maxBytes { + size = maxBytes + } } } diff --git a/client_test.go b/client_test.go index 7fbe783..b0c9ef8 100644 --- a/client_test.go +++ b/client_test.go @@ -1,6 +1,7 @@ package bt import ( + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" ) @@ -53,7 +55,10 @@ func TestNewClientValidation(t *testing.T) { } func TestClientReportDelivery(t *testing.T) { - c, rs := newTestClient(t, nil) + c, rs := newTestClient(t, func(cfg *Config) { + // Source text is opt-in; this test asserts context snippets. + cfg.SourceCode = SourceCodeContext + }) c.Report(errors.New("client error"), map[string]interface{}{"who": "client"}) if !c.Flush(5 * time.Second) { @@ -335,9 +340,11 @@ func TestBreadcrumbsRingAndAnnotation(t *testing.T) { } } -// TestPanicReportRetriesFullQueue pins the bounded blocking enqueue for -// panic reports: with the queue full they retry instead of dropping. -func TestPanicReportRetriesFullQueue(t *testing.T) { +// TestPanicAndFlushRetriesFullQueue pins ReportPanicValueAndFlush: with the +// queue full it retries admission under its single deadline instead of +// dropping, then waits for delivery. Plain ReportPanicValue stays +// non-blocking. +func TestPanicAndFlushRetriesFullQueue(t *testing.T) { block := make(chan struct{}) c, rs := newTestClient(t, func(cfg *Config) { cfg.QueueSize = 1 @@ -358,24 +365,33 @@ func TestPanicReportRetriesFullQueue(t *testing.T) { } c.ReportMessage("fills the queue", nil) - done := make(chan struct{}) + // Plain ReportPanicValue must return immediately (drop + count). + before := c.Stats().QueueFull + c.ReportPanicValue("non-blocking panic", nil) + if got := c.Stats().QueueFull; got != before+1 { + t.Errorf("non-blocking panic with full queue: QueueFull = %d, want %d", got, before+1) + } + + done := make(chan bool, 1) go func() { - c.ReportPanicValue("panic while queue full", nil) - close(done) + done <- c.ReportPanicValueAndFlush("panic while queue full", nil, 10*time.Second) }() select { case <-done: - t.Fatal("panic report returned immediately: dropped instead of retrying") + t.Fatal("ReportPanicValueAndFlush returned immediately: dropped instead of retrying") case <-time.After(100 * time.Millisecond): - // Still retrying, as intended. + // Still retrying under its deadline, as intended. } unblock() select { - case <-done: + case delivered := <-done: + if !delivered { + t.Error("ReportPanicValueAndFlush = false after queue freed within deadline") + } case <-time.After(5 * time.Second): - t.Fatal("panic report never enqueued after queue freed") + t.Fatal("ReportPanicValueAndFlush never returned after queue freed") } if !c.Flush(5 * time.Second) { t.Fatal("Flush timed out") @@ -578,6 +594,278 @@ func TestAttachmentsMultipartSubmission(t *testing.T) { } } +type testPanicMethodError struct{} + +func (*testPanicMethodError) Error() string { panic("Error panic") } + +type testPanicStringer struct{} + +func (testPanicStringer) String() string { panic("String panic") } + +type testPanicLogger struct{} + +func (testPanicLogger) Printf(string, ...interface{}) { panic("logger panic") } + +func mustNotPanic(t *testing.T, name string, f func()) { + t.Helper() + defer func() { + if r := recover(); r != nil { + t.Errorf("%s let an application panic escape: %v", name, r) + } + }() + f() +} + +// TestPublicReportingContainsApplicationPanics pins the "never panic" +// contract at every boundary that executes caller-controlled code: error +// and stringer implementations, unwrap methods, and the diagnostic logger. +func TestPublicReportingContainsApplicationPanics(t *testing.T) { + c, _ := newTestClient(t, func(cfg *Config) { + cfg.Debug = true + cfg.Logger = testPanicLogger{} + }) + + var typedNil *testPanicMethodError + var err error = typedNil // non-nil interface, panicking Error() + + mustNotPanic(t, "ReportError", func() { c.ReportError(err, nil) }) + mustNotPanic(t, "Report", func() { c.Report(testPanicStringer{}, nil) }) + mustNotPanic(t, "ReportPanicValue", func() { c.ReportPanicValue(testPanicStringer{}, nil) }) + mustNotPanic(t, "ReportPanicValueAndFlush", func() { + c.ReportPanicValueAndFlush(testPanicStringer{}, nil, 100*time.Millisecond) + }) + mustNotPanic(t, "Flush", func() { _ = c.Flush(time.Second) }) +} + +// reentrantLogger forwards every diagnostic line back into the SDK — the +// logging-adapter pattern that historically recursed to a fatal stack +// overflow. depth tracks the maximum observed nesting. +type reentrantLogger struct { + c *Client + depth atomic.Int32 + max atomic.Int32 +} + +func (l *reentrantLogger) Printf(format string, v ...interface{}) { + d := l.depth.Add(1) + defer l.depth.Add(-1) + if d > l.max.Load() { + l.max.Store(d) + } + if d > 25 { + // A guard failure would blow the stack long before this, but + // bail out rather than crash the whole test binary. + return + } + l.c.ReportMessage("from logger", nil) +} + +// TestReentrantLoggerIsContained pins the diag re-entrancy guard: a Logger +// that reports back into the SDK must not recurse unboundedly on the +// closed-client or full-queue drop paths. +func TestReentrantLoggerIsContained(t *testing.T) { + logger := &reentrantLogger{} + c, _ := newTestClient(t, func(cfg *Config) { + cfg.Debug = true + cfg.Logger = logger + cfg.QueueSize = 1 + }) + logger.c = c + + // Closed-client drop path: deterministic infinite recursion before + // the guard existed. + c.Close() + c.ReportMessage("after close", nil) + + if got := logger.max.Load(); got > 2 { + t.Errorf("re-entrant logger nested to depth %d; guard not effective", got) + } +} + +// TestSourceMetadataDefault pins the production-safe default: frames carry +// path/line metadata but no source text leaves the host. +func TestSourceMetadataDefault(t *testing.T) { + c, rs := newTestClient(t, nil) // no SourceCode override + + c.ReportMessage("metadata only", nil) + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + sources, _ := rs.last()["sourceCode"].(map[string]interface{}) + if len(sources) == 0 { + t.Fatal("metadata mode should still reference paths") + } + for id, v := range sources { + sc, _ := v.(map[string]interface{}) + if sc == nil { + continue + } + if text, _ := sc["text"].(string); text != "" { + t.Errorf("source entry %s carries text in metadata mode", id) + } + if path, _ := sc["path"].(string); path == "" { + t.Errorf("source entry %s missing path", id) + } + } +} + +// TestFlushIsCaptureTimeBarrier pins the sequence-based flush: reports +// enqueued after Flush is called must not extend its wait. +func TestFlushIsCaptureTimeBarrier(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, func(cfg *Config) { + cfg.QueueSize = 64 + }) + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + c.ReportMessage("pre-flush", nil) + select { + case <-rs.entered: + case <-time.After(3 * time.Second): + t.Fatal("worker never reached the transport") + } + + flushed := make(chan bool, 1) + go func() { flushed <- c.Flush(10 * time.Second) }() + + // Concurrent producers keep pouring reports in AFTER the flush call. + stop := make(chan struct{}) + var wg sync.WaitGroup + for g := 0; g < 4; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + c.ReportMessage("post-flush noise", nil) + time.Sleep(time.Millisecond) + } + } + }() + } + + time.Sleep(50 * time.Millisecond) // flush is now waiting on the barrier + unblock() + + select { + case ok := <-flushed: + if !ok { + t.Error("Flush = false although its pre-call backlog drained") + } + case <-time.After(5 * time.Second): + t.Fatal("Flush starved by post-call producers (not a capture-time barrier)") + } + close(stop) + wg.Wait() + c.Flush(10 * time.Second) +} + +// TestStatsBreakdown pins the reasoned discard counters. +func TestStatsBreakdown(t *testing.T) { + c, rs := newTestClient(t, func(cfg *Config) { + cfg.BeforeSend = func(r *ReportData) *ReportData { + if r.Attributes["drop.me"] == true { + return nil + } + return r + } + }) + + c.ReportMessage("delivered", nil) + c.ReportMessage("hook-dropped", map[string]interface{}{"drop.me": true}) + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + stats := c.Stats() + if stats.Accepted != 2 { + t.Errorf("Accepted = %d, want 2", stats.Accepted) + } + if stats.Delivered != 1 { + t.Errorf("Delivered = %d, want 1", stats.Delivered) + } + if stats.BeforeSend != 1 { + t.Errorf("BeforeSend drops = %d, want 1", stats.BeforeSend) + } + if rs.count() != 1 { + t.Errorf("server received %d reports, want 1", rs.count()) + } + + // Server rejection is classified separately. + rs.mu.Lock() + rs.status = http.StatusInternalServerError + rs.mu.Unlock() + c.ReportMessage("rejected", nil) + c.Flush(5 * time.Second) + if got := c.Stats().ServerReject; got != 1 { + t.Errorf("ServerReject = %d, want 1", got) + } + if c.DroppedReports() != c.Stats().BeforeSend+c.Stats().ServerReject { + t.Errorf("DroppedReports = %d, want sum of reasons", c.DroppedReports()) + } +} + +// TestCloseContextCancelsBlockedTransport pins bounded shutdown: a stuck +// transport cannot hold CloseContext past its deadline, and the SDK root +// context aborts the in-flight request. +func TestCloseContextCancelsBlockedTransport(t *testing.T) { + block := make(chan struct{}) + c, rs := newTestClient(t, nil) + var once sync.Once + unblock := func() { once.Do(func() { close(block) }) } + t.Cleanup(unblock) + + rs.mu.Lock() + rs.block = block + rs.mu.Unlock() + + c.ReportMessage("stuck in flight", nil) + select { + case <-rs.entered: + case <-time.After(3 * time.Second): + t.Fatal("worker never reached the transport") + } + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + start := time.Now() + completed := c.CloseContext(ctx) + if completed { + t.Error("CloseContext reported clean completion despite a blocked transport") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("CloseContext took %v; deadline not honored", elapsed) + } + unblock() + // The worker exits on its own after cancellation aborts the request. + select { + case <-c.workerDone: + case <-time.After(5 * time.Second): + t.Fatal("worker leaked after CloseContext") + } +} + +// TestCaptureStackCap pins the stack budget. +func TestCaptureStackCap(t *testing.T) { + out := captureStack(true, 2048) + if len(out) > 2048 { + t.Errorf("stack = %d bytes, cap 2048", len(out)) + } + if len(out) == 0 { + t.Error("empty stack") + } +} + // TestNilClientIsSafe pins the documented contract: every method on a nil // *Client (ignored NewClient error) is a safe no-op. func TestNilClientIsSafe(t *testing.T) { @@ -592,12 +880,82 @@ func TestNilClientIsSafe(t *testing.T) { if got := c.DroppedReports(); got != 0 { t.Errorf("DroppedReports on nil = %d", got) } + if got := c.Stats(); got != (ClientStats{}) { + t.Errorf("Stats on nil = %+v", got) + } if !c.Flush(time.Millisecond) { t.Error("Flush on nil client should trivially succeed") } + if !c.FlushContext(context.Background()) { + t.Error("FlushContext on nil client should trivially succeed") + } + if !c.ReportPanicValueAndFlush("v", nil, time.Millisecond) { + t.Error("ReportPanicValueAndFlush on nil client should trivially succeed") + } + if !c.CloseContext(context.Background()) { + t.Error("CloseContext on nil client should trivially succeed") + } c.Close() } +// TestNegativeMaxAttachmentsDisables pins the documented contract: a +// negative MaxAttachments sends NO attachments (privacy opt-out), rather +// than removing the count cap. +func TestNegativeMaxAttachmentsDisables(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secret.log") + if err := os.WriteFile(path, []byte("must not upload"), 0o644); err != nil { + t.Fatal(err) + } + + c, rs := newTestClient(t, func(cfg *Config) { + cfg.AttachmentPaths = []string{path} + cfg.MaxAttachments = -1 + }) + c.ReportMessage("no attachments", nil) + if !c.Flush(5 * time.Second) { + t.Fatal("Flush timed out") + } + + if rs.count() != 1 { + t.Fatalf("reports = %d, want 1", rs.count()) + } + for name := range rs.lastAttachments() { + if strings.HasPrefix(name, "attachment_secret") { + t.Errorf("attachment sent despite MaxAttachments=-1: %s", name) + } + } +} + +// TestSourceRootsSymlinkDenied pins the symlink-resolution fix: a link +// under an allowed root pointing outside it must not smuggle content in. +func TestSourceRootsSymlinkDenied(t *testing.T) { + allowedDir := t.TempDir() + secretDir := t.TempDir() + secret := filepath.Join(secretDir, "secret.go") + if err := os.WriteFile(secret, []byte("classified\nlines\nhere\n"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(allowedDir, "linked.go") + if err := os.Symlink(secret, link); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t" + link + ":2 +0x1\n" + + _, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, contextLines: 2, tabWidth: 8, + roots: []string{allowedDir}, + }) + for _, sc := range sources { + if sc.Text != "" { + t.Errorf("symlink escaped SourceRoots: %q", sc.Text) + } + } +} + func TestUUID4Format(t *testing.T) { seen := map[string]bool{} for i := 0; i < 1000; i++ { diff --git a/config.go b/config.go index 4c26ab4..652ded0 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package bt import ( "errors" "fmt" + "math" "net/http" "net/url" "os" @@ -15,36 +16,49 @@ import ( type SourceCodeMode string const ( - // SourceCodeContext embeds only ContextLineCount lines around each - // stack frame. This is the default: it keeps payloads small and avoids - // shipping whole files off the host. + // SourceCodeMetadata reports file path and line metadata for each + // frame but embeds no source text. This is the production-safe + // default: no application source ever leaves the host. + SourceCodeMetadata SourceCodeMode = "metadata" + + // SourceCodeContext embeds ContextLineCount lines around each stack + // frame. Opt-in; consider SourceRoots to constrain which files may + // be read. SourceCodeContext SourceCodeMode = "context" // SourceCodeFile embeds the entire source file referenced by each // frame (the SDK's historical behavior). Opt-in. SourceCodeFile SourceCodeMode = "file" - // SourceCodeNone disables source code capture entirely. + // SourceCodeNone disables source references entirely. SourceCodeNone SourceCodeMode = "none" ) // Defaults applied by NewClient when the corresponding Config field is zero. const ( - // DefaultTimeout is the per-request HTTP timeout. + // DefaultTimeout is the per-request submission deadline. It is + // enforced with an SDK-owned context even when a custom HTTPClient + // is supplied. DefaultTimeout = 30 * time.Second + // DefaultShutdownTimeout bounds Close. + DefaultShutdownTimeout = 5 * time.Second + // DefaultQueueSize is the capacity of the in-memory report queue. // When the queue is full new reports are dropped (never blocking the - // caller) and counted; see Client.DroppedReports. + // caller) and counted; see Client.Stats. DefaultQueueSize = 128 // DefaultContextLineCount is the number of source lines captured // above and below a stack frame line in SourceCodeContext mode. DefaultContextLineCount = 8 - // DefaultMaxErrorDepth caps how many wrapped errors are walked when - // capturing an error chain. - DefaultMaxErrorDepth = 100 + // DefaultMaxErrorDepth caps how deep wrapped-error graphs are walked. + DefaultMaxErrorDepth = 32 + + // DefaultMaxErrorNodes caps how many errors one report's error graph + // may contain (errors.Join fan-out included). + DefaultMaxErrorNodes = 100 // DefaultMaxBreadcrumbs is the capacity of the breadcrumb ring buffer. DefaultMaxBreadcrumbs = 64 @@ -52,11 +66,40 @@ const ( // DefaultTabWidth is reported to the Backtrace UI for source rendering. DefaultTabWidth = 8 - // DefaultFlushTimeout bounds how long panic handlers (ReportPanic, - // ReportAndRecoverPanic) wait for delivery before re-panicking or - // returning, and how long a panic report retries a full queue. - // (FinishSendingReports uses the larger DefaultTimeout.) + // DefaultFlushTimeout bounds how long ReportPanic (and the package- + // level ReportPanicValueAndFlush) waits for capture plus delivery + // before re-panicking or returning. ReportAndRecoverPanic does not + // wait for delivery. DefaultFlushTimeout = 5 * time.Second + + // DefaultMaxStackBytes caps the raw goroutine dump size. + DefaultMaxStackBytes = 4 << 20 + + // DefaultMaxReportBytes caps the serialized JSON report. + DefaultMaxReportBytes = 8 << 20 + + // DefaultMaxAttachments caps the number of attachments per report. + DefaultMaxAttachments = 16 + + // maxQueueSize is a sanity ceiling for explicit queue sizes. + maxQueueSize = 1 << 20 +) + +// Byte budgets for source and attachment capture. +const ( + // DefaultMaxSourceFileBytes caps a single source file read. + DefaultMaxSourceFileBytes int64 = 2 << 20 + + // DefaultMaxSourceBytes caps the total source text embedded in one + // report. + DefaultMaxSourceBytes int64 = 4 << 20 + + // DefaultMaxAttachmentBytes caps one attachment. + DefaultMaxAttachmentBytes int64 = 10 << 20 + + // DefaultMaxTotalAttachmentBytes caps the aggregate attachment bytes + // of one report. + DefaultMaxTotalAttachmentBytes int64 = 25 << 20 ) // Environment variables consulted when the corresponding Config field is empty. @@ -67,6 +110,10 @@ const ( // Config configures a Client. The zero value is not usable: Endpoint is // required (directly or via the BACKTRACE_ENDPOINT environment variable). +// +// NewClient clones every top-level map and slice, so the caller may reuse or +// mutate the Config afterwards. Values stored INSIDE attribute maps are +// retained as given and must not be mutated concurrently with reporting. type Config struct { // Endpoint is the Backtrace submission URL. Two forms are supported: // @@ -86,9 +133,15 @@ type Config struct { // not just the calling goroutine's. CaptureAllGoroutines bool - // SourceCode controls source code embedding. Default: SourceCodeContext. + // SourceCode controls source embedding. Default: SourceCodeMetadata + // (path/line only — no source text leaves the host). Embedding + // source text is opt-in via SourceCodeContext or SourceCodeFile. SourceCode SourceCodeMode + // SourceRoots, when non-empty, restricts source text reads (context + // and file modes) to files under the listed directory roots. + SourceRoots []string + // ContextLineCount is the number of lines captured above and below a // frame's line in SourceCodeContext mode. Default: 8. ContextLineCount int @@ -112,30 +165,39 @@ type Config struct { // of environment variable name patterns whose values are redacted. ScrubEnvVars []string + // SendMachineID includes a stable machine identifier (the "guid" + // attribute) with every report. Default false: stable hardware + // identifiers are privacy-sensitive and opt-in. + SendMachineID bool + // AttachmentPaths lists files attached to every report (multipart // submission, one "attachment_" part per file). Unreadable - // or non-regular files and files larger than 10 MiB are skipped with - // a debug log. Per-report changes can be made in BeforeSend via - // ReportData.Attachments. + // or non-regular files and files over the per-file/aggregate budgets + // are skipped with a debug log. Per-report changes can be made in + // BeforeSend via ReportData.Attachments. AttachmentPaths []string // SampleRate is the fraction of reports actually sent, in [0.0, 1.0]. // The zero value means 1.0 (send everything), so an uninitialized - // Config never silently drops reports. + // Config never silently drops reports. Explicit values outside + // [0, 1], NaN, and infinities are rejected by NewClient. SampleRate float64 // BeforeSend, when set, runs just before a report is serialized. // Return the (optionally modified) report to send it, or nil to drop // it. Runs on the SDK's worker goroutine — do not call Flush or Close // from inside the hook. A panic inside the hook is recovered and the - // report is DROPPED (never sent half-scrubbed) and counted in - // DroppedReports. + // report is DROPPED (never sent half-scrubbed) and counted in Stats. BeforeSend func(report *ReportData) *ReportData - // MaxErrorDepth caps error-chain unwrapping. Default: 100. Negative - // disables chain capture. + // MaxErrorDepth caps error-graph unwrapping depth. Default: 32. + // Negative disables chain capture. MaxErrorDepth int + // MaxErrorNodes caps the total number of errors captured from one + // error graph (relevant for errors.Join trees). Default: 100. + MaxErrorNodes int + // MaxBreadcrumbs caps the breadcrumb ring buffer. Default: 64. // Negative disables breadcrumbs. MaxBreadcrumbs int @@ -143,28 +205,59 @@ type Config struct { // QueueSize is the report queue capacity. Default: 128. QueueSize int - // Timeout is the per-request HTTP timeout. Default: 30s. + // Timeout is the per-request submission deadline, enforced with an + // SDK-owned request context (it applies to custom HTTPClients too). + // Default: 30s. Timeout time.Duration - // HTTPClient overrides the HTTP client used for submission. When set, - // Timeout is not applied to it; configure the client yourself. + // ShutdownTimeout bounds Close. Default: 5s. + ShutdownTimeout time.Duration + + // HTTPClient overrides the HTTP client used for submission. Requests + // still carry the SDK's per-request context deadline (Timeout). + // Cancellation is cooperative: a custom Transport/RoundTripper that + // ignores the request context cannot be forcefully terminated by the + // SDK and can hold the worker (and Close) past its deadline. HTTPClient *http.Client - // DisableMachineAttributes skips the exec-based collection of machine - // metadata (CPU model, OS version, machine GUID). Useful in minimal - // containers without a shell. + // Resource budgets; zero values use the documented defaults. + MaxStackBytes int // raw goroutine dump cap (default 4 MiB) + MaxSourceFileBytes int64 // single source file read cap (default 2 MiB) + MaxSourceBytes int64 // total embedded source per report (default 4 MiB) + MaxReportBytes int // serialized JSON report cap (default 8 MiB) + MaxAttachments int // attachments per report (default 16); negative disables attachments + MaxAttachmentBytes int64 // one attachment (default 10 MiB) + MaxTotalAttachmentBytes int64 // aggregate attachments per report (default 25 MiB) + + // DisableMachineAttributes skips collection of machine metadata + // (CPU model, OS version). DisableMachineAttributes bool - // Debug enables SDK diagnostic logging (report payloads, delivery - // errors, drops). The SDK never panics regardless of this setting. + // Debug enables SDK diagnostic logging: compact, payload-free + // summaries (event ID, type, byte counts, redacted destination, + // outcome). Report payloads are never logged. The SDK never panics + // regardless of this setting. Debug bool // Logger receives diagnostic output when Debug is on. // Default: log.New(os.Stderr, "[backtrace] ", log.LstdFlags). + // A panicking Logger is contained and cannot crash the application. + // A Logger that calls back into the SDK (a logging-adapter pattern) + // is also contained: while a diagnostic line is being written, nested + // diagnostics for the same client are suppressed to break recursion. Logger Logger } -// normalize applies defaults and environment fallbacks. It does not mutate c. +// cloneConfig detaches all top-level collections from caller ownership. +func cloneConfig(c Config) Config { + c.Attributes = cloneAnyMap(c.Attributes) + c.ScrubEnvVars = cloneStringSlice(c.ScrubEnvVars) + c.AttachmentPaths = cloneStringSlice(c.AttachmentPaths) + c.SourceRoots = cloneStringSlice(c.SourceRoots) + return c +} + +// normalize applies defaults to zero values only. It does not mutate c. func (c Config) normalize() Config { if c.Endpoint == "" { c.Endpoint = os.Getenv(envEndpoint) @@ -173,52 +266,127 @@ func (c Config) normalize() Config { c.Token = os.Getenv(envToken) } if c.SourceCode == "" { - c.SourceCode = SourceCodeContext + c.SourceCode = SourceCodeMetadata } - if c.ContextLineCount <= 0 { + if c.ContextLineCount == 0 { c.ContextLineCount = DefaultContextLineCount } - if c.TabWidth <= 0 { + if c.TabWidth == 0 { c.TabWidth = DefaultTabWidth } - if c.SampleRate <= 0 { + if c.SampleRate == 0 { // Zero value means "send everything" so that a Config that never // mentions sampling behaves as expected. c.SampleRate = 1.0 } - if c.SampleRate > 1 { - c.SampleRate = 1.0 - } if c.MaxErrorDepth == 0 { c.MaxErrorDepth = DefaultMaxErrorDepth } + if c.MaxErrorNodes == 0 { + c.MaxErrorNodes = DefaultMaxErrorNodes + } if c.MaxBreadcrumbs == 0 { c.MaxBreadcrumbs = DefaultMaxBreadcrumbs } - if c.QueueSize <= 0 { + if c.QueueSize == 0 { c.QueueSize = DefaultQueueSize } - if c.Timeout <= 0 { + if c.Timeout == 0 { c.Timeout = DefaultTimeout } + if c.ShutdownTimeout == 0 { + c.ShutdownTimeout = DefaultShutdownTimeout + } + if c.MaxStackBytes == 0 { + c.MaxStackBytes = DefaultMaxStackBytes + } + if c.MaxSourceFileBytes == 0 { + c.MaxSourceFileBytes = DefaultMaxSourceFileBytes + } + if c.MaxSourceBytes == 0 { + c.MaxSourceBytes = DefaultMaxSourceBytes + } + if c.MaxReportBytes == 0 { + c.MaxReportBytes = DefaultMaxReportBytes + } + if c.MaxAttachments == 0 { + c.MaxAttachments = DefaultMaxAttachments + } + if c.MaxAttachmentBytes == 0 { + c.MaxAttachmentBytes = DefaultMaxAttachmentBytes + } + if c.MaxTotalAttachmentBytes == 0 { + c.MaxTotalAttachmentBytes = DefaultMaxTotalAttachmentBytes + } return c } -// validate checks that the endpoint is usable. Called with a normalized config. +// validate rejects invalid explicit values rather than silently repairing +// them. Called with a normalized config. func (c Config) validate() error { if c.Endpoint == "" { return errors.New("bt: Config.Endpoint is required (or set BACKTRACE_ENDPOINT)") } + if math.IsNaN(c.SampleRate) || math.IsInf(c.SampleRate, 0) || + c.SampleRate < 0 || c.SampleRate > 1 { + return fmt.Errorf("bt: Config.SampleRate must be finite and in [0,1], got %v", c.SampleRate) + } + if c.QueueSize < 1 || c.QueueSize > maxQueueSize { + return fmt.Errorf("bt: Config.QueueSize must be in [1,%d], got %d", maxQueueSize, c.QueueSize) + } + if c.Timeout <= 0 || c.ShutdownTimeout <= 0 { + return errors.New("bt: Config.Timeout and Config.ShutdownTimeout must be positive") + } + if c.ContextLineCount < 0 || c.TabWidth < 1 { + return errors.New("bt: Config.ContextLineCount must be >= 0 and Config.TabWidth >= 1") + } + if c.MaxErrorNodes < 1 || c.MaxStackBytes < 1 || c.MaxReportBytes < 1 || + c.MaxSourceFileBytes < 1 || c.MaxSourceBytes < 1 || + c.MaxAttachmentBytes < 1 || c.MaxTotalAttachmentBytes < 1 { + return errors.New("bt: one or more resource limits are invalid (must be positive)") + } + if c.MaxAttachmentBytes > c.MaxTotalAttachmentBytes { + return errors.New("bt: Config.MaxAttachmentBytes exceeds MaxTotalAttachmentBytes") + } + switch c.SourceCode { + case SourceCodeMetadata, SourceCodeContext, SourceCodeFile, SourceCodeNone: + default: + return fmt.Errorf("bt: unknown Config.SourceCode mode %q", c.SourceCode) + } + u, err := url.Parse(c.Endpoint) if err != nil { - return fmt.Errorf("bt: invalid Config.Endpoint: %w", err) + // Do not wrap err: *url.Error quotes the complete raw URL, + // which may embed a token, and NewClient errors flow into + // user logs. Surface only the inner reason plus the redacted + // endpoint — and only when the inner reason itself carries no + // quoted input fragment (net/url embeds offending substrings, + // e.g. invalid ports, inside double quotes). + msg := "unparsable URL" + var ue *url.Error + if errors.As(err, &ue) && ue.Err != nil { + if inner := ue.Err.Error(); !strings.Contains(inner, `"`) { + msg = inner + } + } + return fmt.Errorf("bt: invalid Config.Endpoint (%s), got %q", msg, redactURL(c.Endpoint)) } if u.Scheme != "http" && u.Scheme != "https" { // Redact the endpoint in errors: it may embed a token, and // NewClient errors flow into user logs. return fmt.Errorf("bt: Config.Endpoint must be an http(s) URL, got %q", redactURL(c.Endpoint)) } - if c.Token != "" && u.RawQuery != "" { + if u.Host == "" || u.Opaque != "" { + return fmt.Errorf("bt: Config.Endpoint must be an absolute URL with a host, got %q", redactURL(c.Endpoint)) + } + if u.User != nil { + return errors.New("bt: Config.Endpoint must not contain URL userinfo") + } + if u.Fragment != "" { + return errors.New("bt: Config.Endpoint must not contain a fragment") + } + if c.Token != "" && u.RawQuery != "" && + !strings.EqualFold(u.Hostname(), "submit.backtrace.io") { return fmt.Errorf("bt: Config.Endpoint must not carry a query string when Token is set, got %q", redactURL(c.Endpoint)) } return nil @@ -247,3 +415,18 @@ func (c Config) submissionURL() string { // appended path never produces "//post". return fmt.Sprintf("%s/post?%s", strings.TrimRight(c.Endpoint, "/"), v.Encode()) } + +// multipartLimits carries the attachment budgets into the transport. +type multipartLimits struct { + maxAttachments int + maxAttachmentBytes int64 + maxTotalBytes int64 +} + +func (c Config) attachmentLimits() multipartLimits { + return multipartLimits{ + maxAttachments: c.MaxAttachments, + maxAttachmentBytes: c.MaxAttachmentBytes, + maxTotalBytes: c.MaxTotalAttachmentBytes, + } +} diff --git a/config_test.go b/config_test.go index 5fac2ea..d8ba080 100644 --- a/config_test.go +++ b/config_test.go @@ -1,6 +1,9 @@ package bt import ( + "errors" + "fmt" + "math" "strings" "testing" "time" @@ -9,8 +12,8 @@ import ( func TestConfigNormalizeDefaults(t *testing.T) { cfg := Config{Endpoint: "http://example.com"}.normalize() - if cfg.SourceCode != SourceCodeContext { - t.Errorf("SourceCode = %q, want context", cfg.SourceCode) + if cfg.SourceCode != SourceCodeMetadata { + t.Errorf("SourceCode = %q, want metadata (production-safe default)", cfg.SourceCode) } if cfg.ContextLineCount != DefaultContextLineCount { t.Errorf("ContextLineCount = %d", cfg.ContextLineCount) @@ -113,24 +116,84 @@ func TestConfigValidateRejectsQueryWithToken(t *testing.T) { t.Error("endpoint with query + token accepted; submissionURL would be malformed") } // Without a token the endpoint is used verbatim, so a query is fine. - if err := (Config{Endpoint: "https://host/post?format=json&token=t"}).validate(); err != nil { + if err := (Config{Endpoint: "https://host/post?format=json&token=t"}).normalize().validate(); err != nil { t.Errorf("verbatim endpoint with query rejected: %v", err) } } func TestConfigValidate(t *testing.T) { + valid := func(mutate func(*Config)) Config { + c := Config{Endpoint: "https://submit.backtrace.io/u/t/json"}.normalize() + if mutate != nil { + mutate(&c) + } + return c + } + if err := (Config{}).validate(); err == nil { t.Error("empty endpoint accepted") } - if err := (Config{Endpoint: "not a url\x7f"}).validate(); err == nil { + if err := valid(func(c *Config) { c.Endpoint = "not a url\x7f" }).validate(); err == nil { t.Error("unparsable endpoint accepted") } - if err := (Config{Endpoint: "ftp://x"}).validate(); err == nil { + if err := valid(func(c *Config) { c.Endpoint = "ftp://x" }).validate(); err == nil { t.Error("non-http scheme accepted") } - if err := (Config{Endpoint: "https://submit.backtrace.io/u/t/json"}).validate(); err != nil { + if err := valid(nil).validate(); err != nil { t.Errorf("valid endpoint rejected: %v", err) } + + // Strict validation of explicit values (no silent repair). + if err := valid(func(c *Config) { c.SampleRate = -0.5 }).validate(); err == nil { + t.Error("negative SampleRate accepted") + } + if err := valid(func(c *Config) { c.SampleRate = 1.5 }).validate(); err == nil { + t.Error("SampleRate > 1 accepted") + } + if err := valid(func(c *Config) { c.SampleRate = math.NaN() }).validate(); err == nil { + t.Error("NaN SampleRate accepted") + } + if err := valid(func(c *Config) { c.QueueSize = -1 }).validate(); err == nil { + t.Error("negative QueueSize accepted") + } + if err := valid(func(c *Config) { c.SourceCode = "everything" }).validate(); err == nil { + t.Error("unknown SourceCode mode accepted") + } + if err := valid(func(c *Config) { c.Endpoint = "http://" }).validate(); err == nil { + t.Error("host-less endpoint accepted") + } + if err := valid(func(c *Config) { c.Endpoint = "https://user:pw@host/x" }).validate(); err == nil { + t.Error("endpoint with userinfo accepted") + } + if err := valid(func(c *Config) { c.Endpoint = "https://host/x#frag" }).validate(); err == nil { + t.Error("endpoint with fragment accepted") + } + if err := valid(func(c *Config) { c.Timeout = -time.Second }).validate(); err == nil { + t.Error("negative Timeout accepted") + } +} + +// TestValidateNeverLeaksToken pins the credential-redaction contract: even +// unparsable endpoints (where url.Error — or its inner error — would quote +// raw URL fragments) must not leak an embedded token through NewClient +// errors. +func TestValidateNeverLeaksToken(t *testing.T) { + cases := []string{ + "https://uni.sp.backtrace.io/post?token=SUPERSECRETTOKEN\x7f", + "https://uni.sp.backtrace.io/post?token=SUPERSECRETTOKEN\x00", + "ht tp://x/post?token=SUPERSECRETTOKEN", + "https://host:SUPERSECRETTOKEN/post", // token-like text in port position + } + for _, endpoint := range cases { + _, err := NewClient(Config{Endpoint: endpoint}) + if err == nil { + t.Errorf("endpoint %q accepted", endpoint) + continue + } + if strings.Contains(err.Error(), "SUPERSECRETTOKEN") { + t.Errorf("token leaked through NewClient error: %v", err) + } + } } func TestUnwrapErrorChainDepthCap(t *testing.T) { @@ -138,17 +201,45 @@ func TestUnwrapErrorChainDepthCap(t *testing.T) { for i := 1; i < 10; i++ { err = &testWrapErr{msg: string(rune('0' + i)), inner: err} } - if got := len(unwrapErrorChain(err, 3)); got != 3 { - t.Errorf("depth-capped chain = %d, want 3", got) + // Depth 3 admits the root plus three unwrap levels. + if got := len(unwrapErrorChain(err, 3, DefaultMaxErrorNodes)); got != 4 { + t.Errorf("depth-capped chain = %d, want 4", got) } - if got := unwrapErrorChain(err, -1); got != nil { + if got := len(unwrapErrorChain(err, DefaultMaxErrorDepth, 2)); got != 2 { + t.Errorf("node-capped chain = %d, want 2", got) + } + if got := unwrapErrorChain(err, -1, DefaultMaxErrorNodes); got != nil { t.Errorf("negative depth should disable capture, got %d", len(got)) } - if got := unwrapErrorChain(nil, 10); got != nil { + if got := unwrapErrorChain(nil, 10, 10); got != nil { t.Error("nil error should produce no chain") } } +func TestUnwrapErrorChainJoinAndCycles(t *testing.T) { + // errors.Join fan-out is captured with parent/source metadata. + a := errors.New("a") + b := errors.New("b") + joined := errors.Join(a, b) + wrapped := fmt.Errorf("outer: %w", joined) + + chain := unwrapErrorChain(wrapped, DefaultMaxErrorDepth, DefaultMaxErrorNodes) + if len(chain) != 4 { + t.Fatalf("join graph nodes = %d, want 4 (outer, join, a, b)", len(chain)) + } + if chain[2].Source != "errors[0]" || chain[3].Source != "errors[1]" { + t.Errorf("join sources = %q, %q", chain[2].Source, chain[3].Source) + } + + // A cyclic error graph terminates without exhausting the caps. + cyclic := &testWrapErr{msg: "cycle"} + cyclic.inner = cyclic + got := unwrapErrorChain(cyclic, DefaultMaxErrorDepth, DefaultMaxErrorNodes) + if len(got) != 1 { + t.Errorf("cyclic graph nodes = %d, want 1", len(got)) + } +} + type testWrapErr struct { msg string inner error diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..46f7cee --- /dev/null +++ b/errors.go @@ -0,0 +1,8 @@ +package bt + +import "errors" + +// ErrUnsupportedPlatform is returned (wrapped) by operations that have no +// implementation on the current platform, such as tracer snapshot upload on +// macOS or process-tracing setup outside Linux. Test with errors.Is. +var ErrUnsupportedPlatform = errors.New("bt: operation unsupported on this platform") diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..f1f9998 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,106 @@ +package bt + +import ( + "net/http" + "strings" + "testing" +) + +func newRetryAfterResponse(value string) *http.Response { + h := http.Header{} + if value != "" { + h.Set("Retry-After", value) + } + return &http.Response{Header: h} +} + +// FuzzBuildThreads exercises the stack parser with arbitrary input: it must +// never panic and never read source text in metadata mode. +func FuzzBuildThreads(f *testing.F) { + f.Add(stackFixture) + f.Add("goroutine 1 [running]:\nmain.main()\n\tC:/x/y.go:42 +0x1f\n") + f.Add("...additional frames elided...\n\n[originating from goroutine 3]:\n") + f.Add("goroutine running on other thread; stack unavailable\n") + f.Add("panic({0x1?, 0x2?})\n\tx:1\n") + f.Fuzz(func(t *testing.T, stack string) { + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeMetadata, contextLines: 4, tabWidth: 8, + }) + for _, sc := range sources { + if sc.Text != "" { + t.Fatalf("metadata mode produced source text: %q", sc.Text) + } + } + _ = threads + }) +} + +// FuzzRedactURL: whatever the input, no query token value or userinfo +// password may survive into the output. +func FuzzRedactURL(f *testing.F) { + f.Add("https://uni.sp.backtrace.io/post?format=json&token=SECRETCANARY") + f.Add("https://submit.backtrace.io/universe/SECRETCANARY/json") + f.Add("https://user:SECRETCANARY@host/path") + f.Add("http://%zz") + f.Fuzz(func(t *testing.T, raw string) { + out := redactURL(raw) + if strings.Contains(raw, "SECRETCANARY") && + strings.Contains(out, "SECRETCANARY") && + (strings.Contains(raw, "token=SECRETCANARY") || + strings.Contains(raw, ":SECRETCANARY@")) { + t.Fatalf("credential survived redaction: %q -> %q", raw, out) + } + }) +} + +// FuzzRetryAfter: arbitrary header values must produce a bounded, +// non-negative pause. +func FuzzRetryAfter(f *testing.F) { + f.Add("30") + f.Add("2147483647") + f.Add("-5") + f.Add("Wed, 21 Oct 2015 07:28:00 GMT") + f.Add("garbage") + f.Fuzz(func(t *testing.T, header string) { + resp := newRetryAfterResponse(header) + d := retryAfter(resp) + if d < 0 || d > rateLimitMaxPause { + t.Fatalf("retryAfter(%q) = %v, outside [0, %v]", header, d, rateLimitMaxPause) + } + }) +} + +// FuzzSplitQualifiedFunction must never panic and never lose the input. +func FuzzSplitQualifiedFunction(f *testing.F) { + f.Add("testing.(*T).Run(0x1, {0x2, 0x9}, 0x3)") + f.Add("pkg.(*Cache[go.shape.string]).Get(0x1)") + f.Add("panic({0x1?, 0x2?})") + f.Add("noDots") + f.Add("[[[[") + f.Fuzz(func(t *testing.T, line string) { + lib, fn := splitQualifiedFunction(line) + _ = lib + _ = fn + }) +} + +// FuzzSafeMultipartName: outputs must be non-empty and free of control +// characters, quotes, and separators that could corrupt multipart framing. +func FuzzSafeMultipartName(f *testing.F) { + f.Add("/var/log/app.log") + f.Add("/tmp/evil\r\nname\x00\"") + f.Add("") + f.Add("..") + f.Fuzz(func(t *testing.T, path string) { + name := safeMultipartName(path) + if name == "" { + t.Fatal("empty multipart name") + } + if strings.ContainsAny(name, "\r\n\x00\"\\") { + t.Fatalf("unsafe characters survived: %q", name) + } + if len(name) > maxMultipartNameLength { + t.Fatalf("name too long: %d", len(name)) + } + }) +} diff --git a/logger.go b/logger.go index 4e2bd4f..4dfb3b7 100644 --- a/logger.go +++ b/logger.go @@ -3,6 +3,7 @@ package bt import ( "log" "os" + "sync/atomic" ) // Logger is the minimal logging interface used for SDK diagnostics. @@ -21,6 +22,14 @@ var defaultDiagLogger Logger = log.New(os.Stderr, "[backtrace] ", log.LstdFlags) type diag struct { logger Logger debug bool + + // busy, when set, is a per-client re-entrancy guard: a Logger that + // calls back into the SDK (a logging-adapter pattern) would otherwise + // recurse Printf -> Report -> drop -> logf -> Printf without bound, + // ending in an uncatchable stack overflow. While a diagnostic line is + // being written, nested (and concurrent) diagnostics for the same + // client are suppressed. + busy *atomic.Int32 } func (d diag) logf(format string, v ...interface{}) { @@ -31,5 +40,17 @@ func (d diag) logf(format string, v ...interface{}) { if l == nil { l = defaultDiagLogger } + + if d.busy != nil { + if !d.busy.CompareAndSwap(0, 1) { + return + } + defer d.busy.Store(0) + } + + // A diagnostic callback must never be able to terminate the + // application or the SDK worker. Do not recursively attempt to log + // this panic. + defer func() { _ = recover() }() l.Printf(format, v...) } diff --git a/main.go b/main.go index 2eb3113..1801475 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,13 @@ // Configure Options before the first report. For runtime attribute changes // use SetAttribute (safe for concurrent use) instead of mutating // Options.Attributes. +// +// # Scope +// +// This SDK reports errors, messages, and recovered panics from within the +// process. It cannot capture crashes that bypass Go panics (native/cgo +// faults, runtime aborts); for those, use the bcd out-of-process tracer +// integration in this package or the Backtrace Coresnap workflow. package bt import ( @@ -54,43 +61,53 @@ type OptionsStruct struct { // Attributes are added to every report. Prefer SetAttribute for // changes made while the application is running. Attributes map[string]interface{} - // DebugBacktrace enables SDK diagnostic logging. Unlike historical - // versions, the SDK never panics: failures are logged instead. + // DebugBacktrace enables SDK diagnostic logging (payload-free + // summaries). Unlike historical versions, the SDK never panics: + // failures are logged instead. DebugBacktrace bool // SourceCode controls source embedding; see Config.SourceCode. - // Default: SourceCodeContext (historical behavior was whole files; - // opt back in with SourceCodeFile). + // Default: SourceCodeMetadata (path/line only). Historical behavior + // (whole files) is available with SourceCodeFile. SourceCode SourceCodeMode + // SourceRoots restricts source text reads; see Config.SourceRoots. + SourceRoots []string // SampleRate is the fraction of reports sent; zero means 1.0. SampleRate float64 // BeforeSend runs before each report is serialized; see Config.BeforeSend. BeforeSend func(report *ReportData) *ReportData // ScrubEnvVars extends the built-in redaction patterns; see Config.ScrubEnvVars. ScrubEnvVars []string + // SendMachineID includes a stable machine identifier; see + // Config.SendMachineID. Default false. + SendMachineID bool // Logger receives diagnostics when DebugBacktrace is on. Logger Logger // HTTPClient overrides the submission HTTP client. HTTPClient *http.Client - // Timeout is the per-request HTTP timeout (default 30s). Applied when - // the default client is created (first report). + // Timeout is the per-request submission deadline (default 30s). + // Applied when the default client is created (first report). Timeout time.Duration + // ShutdownTimeout bounds client shutdown; see Config.ShutdownTimeout. + ShutdownTimeout time.Duration // QueueSize is the report queue capacity (default 128). Applied when // the default client is created (first report). QueueSize int - // MaxErrorDepth caps error-chain unwrapping; see Config.MaxErrorDepth. + // MaxErrorDepth caps error-graph unwrapping; see Config.MaxErrorDepth. MaxErrorDepth int // MaxBreadcrumbs caps the breadcrumb buffer; applied at first report. MaxBreadcrumbs int // AttachmentPaths lists files attached to every report. AttachmentPaths []string - // DisableMachineAttributes skips exec-based machine metadata collection. + // DisableMachineAttributes skips machine metadata collection. DisableMachineAttributes bool } // Options configures the legacy global reporter. Set fields before the // first report; concurrent mutation while reporting is not synchronized -// (use SetAttribute / SetAttributes for runtime attribute updates). +// (use SetAttribute / SetAttributes for runtime attribute updates). The +// delivery configuration is snapshotted when each report is captured, so +// later changes cannot reroute already-queued reports. var Options OptionsStruct func init() { @@ -103,13 +120,11 @@ func init() { // alongside the legacy global API. var legacyAttrMu sync.Mutex -// optionsToConfig snapshots the global Options into a Config. +// optionsToConfig snapshots the global Options into a Config, cloning every +// collection so queued reports cannot observe later mutations. func optionsToConfig() Config { legacyAttrMu.Lock() - attrs := make(map[string]interface{}, len(Options.Attributes)) - for k, v := range Options.Attributes { - attrs[k] = v - } + attrs := cloneAnyMap(Options.Attributes) legacyAttrMu.Unlock() return Config{ @@ -122,16 +137,19 @@ func optionsToConfig() Config { Attributes: attrs, Debug: Options.DebugBacktrace, SourceCode: Options.SourceCode, + SourceRoots: cloneStringSlice(Options.SourceRoots), SampleRate: Options.SampleRate, BeforeSend: Options.BeforeSend, - ScrubEnvVars: Options.ScrubEnvVars, + ScrubEnvVars: cloneStringSlice(Options.ScrubEnvVars), + SendMachineID: Options.SendMachineID, Logger: Options.Logger, HTTPClient: Options.HTTPClient, Timeout: Options.Timeout, + ShutdownTimeout: Options.ShutdownTimeout, QueueSize: Options.QueueSize, MaxErrorDepth: Options.MaxErrorDepth, MaxBreadcrumbs: Options.MaxBreadcrumbs, - AttachmentPaths: Options.AttachmentPaths, + AttachmentPaths: cloneStringSlice(Options.AttachmentPaths), DisableMachineAttributes: Options.DisableMachineAttributes, }.normalize() } @@ -139,27 +157,46 @@ func optionsToConfig() Config { var ( defaultClientMu sync.Mutex defaultClientV *Client + + // disabledWarnOnce rate-limits the unconditional misconfiguration + // warning to a single line per process. + disabledWarnOnce sync.Once ) // defaultClient lazily creates the client backing the legacy global API. -// Returns nil while Options.Endpoint (and BACKTRACE_ENDPOINT) are unset: -// the global API is then a safe no-op. +// Returns nil while Options.Endpoint (and BACKTRACE_ENDPOINT) are unset or +// invalid: the global API is then a safe no-op. func defaultClient() *Client { defaultClientMu.Lock() - defer defaultClientMu.Unlock() if defaultClientV != nil { - return defaultClientV + c := defaultClientV + defaultClientMu.Unlock() + return c } cfg := optionsToConfig() if err := cfg.validate(); err != nil { - diag{logger: Options.Logger, debug: Options.DebugBacktrace}.logf("reporting disabled: %v", err) + defaultClientMu.Unlock() + // Log outside the lock: the logger is application code. + diag{logger: cfg.Logger, debug: cfg.Debug}.logf("reporting disabled: %v", err) + // An endpoint was configured but rejected: the application + // clearly intended reporting, so surface the misconfiguration + // once even without debug mode (an empty endpoint stays + // silent — that is the documented no-op mode). + if cfg.Endpoint != "" { + disabledWarnOnce.Do(func() { + defaultDiagLogger.Printf("reporting disabled: %v", err) + }) + } return nil } // The legacy client re-reads Options on every report so historical // patterns (mutating bt.Options at runtime) keep working; queue size, - // timeout, and HTTP client are fixed at creation. - defaultClientV = startClient(optionsToConfig) - return defaultClientV + // timeout, and HTTP client are fixed at creation, and each report + // snapshots the delivery configuration at capture time. + c := startClient(optionsToConfig) + defaultClientV = c + defaultClientMu.Unlock() + return c } // Report sends an error report through the legacy global reporter. object @@ -172,43 +209,79 @@ func Report(object interface{}, extraAttributes map[string]interface{}) { } } +// reportingConfigured decides whether the deferred panic helpers should act +// WITHOUT instantiating the default client — the helpers run on every +// deferred return, and client startup belongs on the (rare) panic path. +func reportingConfigured() bool { + if currentDefaultClient() != nil { + return true + } + return optionsToConfig().validate() == nil +} + // ReportPanic reports a panic and re-panics with the original value, after -// waiting up to DefaultFlushTimeout for delivery. Use with defer: +// waiting up to DefaultFlushTimeout (one deadline covering capture and +// delivery). Use with defer: // // defer bt.ReportPanic(nil) +// +// While the SDK is unconfigured this function does NOT recover: the +// original panic proceeds exactly as if the handler were absent. func ReportPanic(extraAttributes map[string]interface{}) { + if !reportingConfigured() { + // Crucial: do not call recover. The original panic continues + // exactly as it did before SDK configuration. + return + } v := recover() if v == nil { return } if c := defaultClient(); c != nil { - c.ReportPanicValue(v, extraAttributes) - c.Flush(DefaultFlushTimeout) + _ = c.ReportPanicValueAndFlush(v, extraAttributes, DefaultFlushTimeout) } panic(v) } // ReportAndRecoverPanic reports a panic and swallows it; the goroutine -// lives on. Use with defer. +// lives on. Use with defer. While the SDK is unconfigured this function +// does NOT recover: the original panic proceeds unchanged. func ReportAndRecoverPanic(extraAttributes map[string]interface{}) { + if !reportingConfigured() { + return // no recover; preserve the application's panic + } v := recover() if v == nil { return } if c := defaultClient(); c != nil { c.ReportPanicValue(v, extraAttributes) + return } + // Configuration became invalid between the check and client creation + // (rare race): keep the panic alive rather than silently swallow it. + panic(v) } // ReportPanicValue reports an already-recovered panic value through the -// legacy global reporter without re-panicking. Intended for middleware and -// custom recover() handlers. +// legacy global reporter without re-panicking or blocking. Intended for +// middleware and custom recover() handlers. func ReportPanicValue(value interface{}, extraAttributes map[string]interface{}) { if c := defaultClient(); c != nil { c.ReportPanicValue(value, extraAttributes) } } +// ReportPanicValueAndFlush reports an already-recovered panic value and +// waits for its delivery under one deadline. Returns false when the SDK is +// unconfigured or delivery did not complete in time. +func ReportPanicValueAndFlush(value interface{}, extraAttributes map[string]interface{}, timeout time.Duration) bool { + if c := defaultClient(); c != nil { + return c.ReportPanicValueAndFlush(value, extraAttributes, timeout) + } + return false +} + // SetAttribute sets a global attribute included in every subsequent report. // Safe for concurrent use; prefer this over mutating Options.Attributes. func SetAttribute(key string, value interface{}) { @@ -241,7 +314,8 @@ func AddBreadcrumb(b Breadcrumb) { // Flush blocks until reports queued at the time of the call are processed // or timeout elapses, reporting whether the drain completed. The reporter -// stays fully usable afterwards. +// stays fully usable afterwards. Flushing proves local processing and send +// completion, not backend acceptance. func Flush(timeout time.Duration) bool { c := currentDefaultClient() if c == nil { @@ -251,9 +325,10 @@ func Flush(timeout time.Duration) bool { } // FinishSendingReports blocks until queued reports are sent (bounded by the -// configured HTTP timeout per report, 30s overall). Unlike historical -// versions it does NOT stop the reporter: reporting continues to work -// afterwards. Kept for backward compatibility — new code should use Flush. +// configured submission deadline per report, 30s overall). Unlike +// historical versions it does NOT stop the reporter: reporting continues to +// work afterwards. Kept for backward compatibility — new code should use +// Flush. func FinishSendingReports() { Flush(DefaultTimeout) } diff --git a/main_test.go b/main_test.go index f78d0e2..b80e1b0 100644 --- a/main_test.go +++ b/main_test.go @@ -312,6 +312,55 @@ func TestSetAttributeIsConcurrencySafe(t *testing.T) { } } +// resetDefaultClientForTest detaches the process-global default client so a +// test can exercise the unconfigured path, restoring everything afterwards. +func resetDefaultClientForTest(t *testing.T) { + t.Helper() + defaultClientMu.Lock() + oldClient := defaultClientV + defaultClientV = nil + defaultClientMu.Unlock() + oldOptions := Options + t.Cleanup(func() { + defaultClientMu.Lock() + defaultClientV = oldClient + defaultClientMu.Unlock() + Options = oldOptions + }) +} + +// TestUnconfiguredPanicHelpersDoNotRecover pins the legacy semantic: while +// reporting is unconfigured, the deferred panic helpers must not touch the +// panic at all — the application's panic proceeds exactly as if the handler +// were absent. +func TestUnconfiguredPanicHelpersDoNotRecover(t *testing.T) { + resetDefaultClientForTest(t) + t.Setenv(envEndpoint, "") + t.Setenv(envToken, "") + Options.Endpoint = "" + Options.Token = "" + + recoverValue := func(f func()) (v interface{}) { + defer func() { v = recover() }() + f() + return nil + } + + if got := recoverValue(func() { + defer ReportAndRecoverPanic(nil) + panic("original") + }); got != "original" { + t.Fatalf("ReportAndRecoverPanic swallowed an unconfigured panic: recovered %#v, want it to propagate", got) + } + + if got := recoverValue(func() { + defer ReportPanic(nil) + panic("original2") + }); got != "original2" { + t.Fatalf("ReportPanic altered an unconfigured panic: recovered %#v", got) + } +} + func TestLegacyEnvVarAnnotations(t *testing.T) { legacyServer.reset() t.Setenv("BT_TEST_SECRET_TOKEN", "hunter2") diff --git a/report.go b/report.go index db80727..566cf36 100644 --- a/report.go +++ b/report.go @@ -2,9 +2,13 @@ package bt import ( "crypto/rand" - "errors" + "crypto/sha256" "fmt" + "os" + "reflect" "runtime" + "sync/atomic" + "time" ) // ReportData is a fully assembled crash/error report, exposed to the @@ -20,7 +24,7 @@ type ReportData struct { Timestamp int64 // Classifiers group the report in the Backtrace UI; the SDK sets - // exactly one of "error", "panic", or "message". Error-chain type + // exactly one of "error", "panic", or "message". Error-graph type // names are reported via the "error.type" attribute and the // "Error Chain" annotation, not as classifiers. Classifiers []string @@ -66,42 +70,101 @@ func (r *ReportData) toWire() map[string]interface{} { } } -// uuid4 returns an RFC 4122 version 4 UUID from crypto/rand. +var uuidFallbackCounter atomic.Uint64 + +// uuid4 returns an RFC 4122 version 4 UUID from crypto/rand. If crypto/rand +// somehow fails (documented never to happen on supported platforms), the +// fallback derives distinct bytes from time, PID, and a counter rather than +// producing a process-wide constant. func uuid4() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { - // crypto/rand is documented never to fail on supported - // platforms; if it somehow does, a constant-free fallback is - // still preferable to panicking inside a crash reporter. - for i := range b { - b[i] = byte(i * 17) - } + n := uuidFallbackCounter.Add(1) + seed := fmt.Sprintf("%d:%d:%d", time.Now().UnixNano(), os.Getpid(), n) + sum := sha256.Sum256([]byte(seed)) + copy(b[:], sum[:16]) } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } -// errorChainLink describes one error in an unwrapped chain. +// errorChainLink describes one node in an unwrapped error graph. type errorChainLink struct { - Type string `json:"type"` - Message string `json:"message"` + ID int `json:"id"` + ParentID *int `json:"parentId,omitempty"` + Source string `json:"source,omitempty"` + Type string `json:"type"` + Message string `json:"message"` } -// unwrapErrorChain walks err's Unwrap chain (up to maxDepth links) and -// returns the chain description (Go type name plus message per link), used -// for the "error.type" attribute and the "Error Chain" annotation. -// A maxDepth < 0 disables chain capture. -func unwrapErrorChain(err error, maxDepth int) []errorChainLink { - if err == nil || maxDepth < 0 { +// unwrapErrorChain walks err's unwrap graph — both Unwrap() error and +// Unwrap() []error (errors.Join) forms — bounded by depth and total node +// count, with cycle detection. All application-defined methods are invoked +// behind panic containment. A maxDepth < 0 disables capture. +func unwrapErrorChain(err error, maxDepth, maxNodes int) []errorChainLink { + if err == nil || maxDepth < 0 || maxNodes < 1 { return nil } - var chain []errorChainLink - for e := err; e != nil && len(chain) < maxDepth; e = errors.Unwrap(e) { - chain = append(chain, errorChainLink{ - Type: fmt.Sprintf("%T", e), - Message: e.Error(), + links := make([]errorChainLink, 0, 8) + seen := make(map[string]struct{}) + + var visit func(current error, depth int, parent *int, source string) + visit = func(current error, depth int, parent *int, source string) { + if current == nil || depth > maxDepth || len(links) >= maxNodes { + return + } + key := errorVisitKey(current) + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + + id := len(links) + links = append(links, errorChainLink{ + ID: id, + ParentID: parent, + Source: source, + Type: fmt.Sprintf("%T", current), + Message: safeErrorString(current), }) + parentID := id + + if many := safeUnwrapMany(current); many != nil { + for i, child := range many { + visit(child, depth+1, &parentID, fmt.Sprintf("errors[%d]", i)) + } + return + } + visit(safeUnwrapOne(current), depth+1, &parentID, "unwrap") + } + + visit(err, 0, nil, "") + return links +} + +// errorVisitKey identifies an error for cycle detection: pointer identity +// where available, type+message otherwise. +func errorVisitKey(err error) string { + rv := reflect.ValueOf(err) + if rv.IsValid() && rv.Kind() == reflect.Pointer && !rv.IsNil() { + return fmt.Sprintf("%T@%x", err, rv.Pointer()) + } + return fmt.Sprintf("%T:%s", err, safeErrorString(err)) +} + +func safeUnwrapOne(err error) (out error) { + defer func() { _ = recover() }() + if u, ok := err.(interface{ Unwrap() error }); ok { + return u.Unwrap() + } + return nil +} + +func safeUnwrapMany(err error) (out []error) { + defer func() { _ = recover() }() + if u, ok := err.(interface{ Unwrap() []error }); ok { + return append([]error(nil), u.Unwrap()...) } - return chain + return nil } diff --git a/safety.go b/safety.go new file mode 100644 index 0000000..cae4c6a --- /dev/null +++ b/safety.go @@ -0,0 +1,53 @@ +package bt + +import "fmt" + +const formattingPanicPlaceholder = "value panicked while being formatted" + +// safeSprint is used at every boundary that may execute application-defined +// String or Format methods. It must never call the diagnostic logger: the +// logger is application code too. +func safeSprint(v interface{}) (out string) { + defer func() { + if recover() != nil { + out = fmt.Sprintf("<%T: %s>", v, formattingPanicPlaceholder) + } + }() + return fmt.Sprint(v) +} + +// safeErrorString is the error-specific equivalent of safeSprint. A typed-nil +// error is a non-nil interface and may panic in Error(); this helper contains +// that case. +func safeErrorString(err error) (out string) { + if err == nil { + return "" + } + defer func() { + if recover() != nil { + out = fmt.Sprintf("<%T: Error() panicked>", err) + } + }() + return err.Error() +} + +// cloneStringSlice detaches the slice header from caller ownership. +func cloneStringSlice(in []string) []string { + if in == nil { + return nil + } + return append([]string(nil), in...) +} + +// cloneAnyMap detaches the map header from caller ownership. Nested reference +// values are documented as immutable after the reporting call returns. +func cloneAnyMap(in map[string]interface{}) map[string]interface{} { + if in == nil { + return nil + } + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/threads.go b/threads.go index e641076..25d26b9 100644 --- a/threads.go +++ b/threads.go @@ -1,7 +1,9 @@ package bt import ( + "io" "os" + "path/filepath" "strconv" "strings" ) @@ -43,6 +45,12 @@ type sourceOptions struct { mode SourceCodeMode contextLines int tabWidth int + // roots, when non-empty, restricts which files may be read. + roots []string + // maxFileBytes caps a single file read; maxTotal caps the aggregate + // source text embedded in one report. + maxFileBytes int64 + maxTotal int64 } // ParseThreadsFromStack parses runtime.Stack output into the Backtrace @@ -54,6 +62,9 @@ func ParseThreadsFromStack(stackTrace []byte) (map[string]Thread, map[string]Sou mode: cfg.SourceCode, contextLines: cfg.ContextLineCount, tabWidth: cfg.TabWidth, + roots: cfg.SourceRoots, + maxFileBytes: cfg.MaxSourceFileBytes, + maxTotal: cfg.MaxSourceBytes, }) return threads, sourceCodes } @@ -126,7 +137,7 @@ func buildThreads(stackTrace []byte, opts sourceOptions) (map[string]Thread, map continue } qualified := trimCreatedBy(line) - if strings.HasPrefix(qualified, sdkFramePrefix) { + if isSDKFrame(qualified) { if len(current.Stacks) == 0 { sdkOnly = true } @@ -215,6 +226,15 @@ func splitQualifiedFunction(line string) (library, function string) { return line[:lastDot], line[lastDot+1:] } +// isSDKFrame reports whether a qualified function name belongs to this SDK, +// requiring an import-path boundary so sibling module paths (e.g. +// ".../backtrace-go-fork") are never filtered. +func isSDKFrame(name string) bool { + return name == sdkFramePrefix || + strings.HasPrefix(name, sdkFramePrefix+"/") || + strings.HasPrefix(name, sdkFramePrefix+".") +} + // trimCreatedBy reduces "created by pkg.fn in goroutine 7" to "pkg.fn". func trimCreatedBy(line string) string { if strings.HasPrefix(line, "created by") { @@ -224,17 +244,30 @@ func trimCreatedBy(line string) string { return line } -// sourceBuilder deduplicates and extracts source snippets for stack frames. +// sourceBuilder deduplicates and extracts source snippets for stack frames, +// under per-file and per-report byte budgets and an optional root allowlist. type sourceBuilder struct { - opts sourceOptions - ids map[string]string // dedup key -> snippet ID - entries map[string]SourceCode - files map[string][]string // per-report file line cache - failed map[string]bool - nextID int + opts sourceOptions + ids map[string]string // dedup key -> snippet ID + entries map[string]SourceCode + files map[string][]string // per-report file line cache + failed map[string]bool + nextID int + usedBytes int64 // embedded source text so far (budget accounting) } func newSourceBuilder(opts sourceOptions) *sourceBuilder { + // Normalize roots to absolute paths once: stack traces carry absolute + // file paths, so a relative root would silently never match. + if len(opts.roots) > 0 { + normalized := make([]string, 0, len(opts.roots)) + for _, root := range opts.roots { + if abs, err := filepath.Abs(root); err == nil { + normalized = append(normalized, abs) + } + } + opts.roots = normalized + } return &sourceBuilder{ opts: opts, ids: map[string]string{}, @@ -262,7 +295,12 @@ func (b *sourceBuilder) reference(path, lineNo string) string { id := strconv.Itoa(b.nextID) b.nextID++ b.ids[key] = id - b.entries[id] = b.extract(path, lineNo) + if b.opts.mode == SourceCodeMetadata { + // Path/line metadata only; no source text leaves the host. + b.entries[id] = SourceCode{Path: path} + } else { + b.entries[id] = b.extract(path, lineNo) + } return id } @@ -271,19 +309,26 @@ func (b *sourceBuilder) result() map[string]SourceCode { } // extract builds the snippet for path around lineNo according to the mode. -// Unreadable files degrade to a path-only entry. +// Unreadable, disallowed, or over-budget files degrade to path-only entries. func (b *sourceBuilder) extract(path, lineNo string) SourceCode { sc := SourceCode{Path: path} + // Short-circuit before reading: once the per-report budget is + // exhausted no further file I/O is useful. + if b.opts.maxTotal > 0 && b.usedBytes >= b.opts.maxTotal { + return sc + } + lines := b.readLines(path) if lines == nil { return sc } + var text string + startLine := 1 switch b.opts.mode { case SourceCodeFile: - sc.Text = strings.Join(lines, "\n") - sc.StartLine = 1 + text = strings.Join(lines, "\n") default: // SourceCodeContext center, err := strconv.Atoi(lineNo) if err != nil || center < 1 { @@ -300,17 +345,52 @@ func (b *sourceBuilder) extract(path, lineNo string) SourceCode { if start > len(lines) { return sc } - sc.Text = strings.Join(lines[start-1:end], "\n") - sc.StartLine = start + text = strings.Join(lines[start-1:end], "\n") + startLine = start + } + + // Enforce the per-report source budget. + if b.opts.maxTotal > 0 && b.usedBytes+int64(len(text)) > b.opts.maxTotal { + return sc } + b.usedBytes += int64(len(text)) + sc.Text = text + sc.StartLine = startLine sc.StartColumn = 1 sc.StartPos = 0 sc.TabWidth = b.opts.tabWidth return sc } -// readLines reads and caches a source file for the duration of one report. +// pathAllowed applies the SourceRoots allowlist: when roots are configured, +// only files under one of them may be read. Both the candidate and the +// roots are symlink-resolved so a link placed under an allowed root cannot +// smuggle in content from outside it; an unresolvable candidate is denied. +func (b *sourceBuilder) pathAllowed(path string) bool { + if len(b.opts.roots) == 0 { + return true + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return false + } + for _, root := range b.opts.roots { + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + continue + } + if resolved == resolvedRoot || + strings.HasPrefix(resolved, resolvedRoot+string(filepath.Separator)) { + return true + } + } + return false +} + +// readLines reads and caches a source file for the duration of one report, +// bounded by the per-file byte budget and restricted to a regular file +// inside the configured roots. func (b *sourceBuilder) readLines(path string) []string { if lines, ok := b.files[path]; ok { return lines @@ -318,11 +398,35 @@ func (b *sourceBuilder) readLines(path string) []string { if b.failed[path] { return nil } - data, err := os.ReadFile(path) - if err != nil { + fail := func() []string { b.failed[path] = true return nil } + if !b.pathAllowed(path) { + return fail() + } + + file, err := os.Open(path) + if err != nil { + return fail() + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return fail() + } + maxBytes := b.opts.maxFileBytes + if maxBytes <= 0 { + maxBytes = DefaultMaxSourceFileBytes + } + if info.Size() > maxBytes { + return fail() + } + data, err := io.ReadAll(io.LimitReader(file, maxBytes+1)) + if err != nil || int64(len(data)) > maxBytes { + return fail() + } + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") b.files[path] = lines return lines diff --git a/threads_test.go b/threads_test.go index 6511789..3af17da 100644 --- a/threads_test.go +++ b/threads_test.go @@ -378,6 +378,113 @@ func TestParseThreadsFromStackLegacyWrapper(t *testing.T) { } } +func TestSourceMetadataMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "meta.go") + if err := os.WriteFile(path, []byte("secret line\n"), 0o644); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.main()\n" + + "\t" + path + ":1 +0x1f\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeMetadata, contextLines: 8, tabWidth: 8, + }) + sc := sources[threads["0"].Stacks[0].SourceCodeID] + if sc.Path != path { + t.Errorf("metadata path = %q", sc.Path) + } + if sc.Text != "" { + t.Errorf("metadata mode leaked source text: %q", sc.Text) + } +} + +func TestSourceRootsAllowlist(t *testing.T) { + allowedDir := t.TempDir() + deniedDir := t.TempDir() + allowed := filepath.Join(allowedDir, "in.go") + denied := filepath.Join(deniedDir, "out.go") + for _, p := range []string{allowed, denied} { + if err := os.WriteFile(p, []byte("one\ntwo\nthree\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t" + allowed + ":2 +0x1\n" + + "main.b()\n" + + "\t" + denied + ":2 +0x2\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, contextLines: 2, tabWidth: 8, + roots: []string{allowedDir}, + }) + frames := threads["0"].Stacks + if sources[frames[0].SourceCodeID].Text == "" { + t.Error("allowed root produced no source text") + } + if got := sources[frames[1].SourceCodeID].Text; got != "" { + t.Errorf("file outside SourceRoots was read: %q", got) + } +} + +func TestSourceBudgets(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "big.go") + content := strings.Repeat("padding line\n", 100) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t" + path + ":50 +0x1\n" + + // Per-file budget smaller than the file: degrade to path-only. + _, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, contextLines: 3, tabWidth: 8, + maxFileBytes: 64, + }) + for _, sc := range sources { + if sc.Text != "" { + t.Errorf("per-file budget ignored: %d bytes embedded", len(sc.Text)) + } + } + + // Total budget of 1 byte: snippet cannot be embedded. + _, sources, _ = buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, contextLines: 3, tabWidth: 8, + maxTotal: 1, + }) + for _, sc := range sources { + if sc.Text != "" { + t.Errorf("total source budget ignored: %d bytes embedded", len(sc.Text)) + } + } +} + +func TestIsSDKFrameBoundary(t *testing.T) { + cases := []struct { + name string + want bool + }{ + {"github.com/backtrace-labs/backtrace-go.Report", true}, + {"github.com/backtrace-labs/backtrace-go", true}, + {"github.com/backtrace-labs/backtrace-go/bthttp.(*Handler).Handle", true}, + {"github.com/backtrace-labs/backtrace-go-fork.Report", false}, + {"github.com/backtrace-labs/backtrace-gopher.Report", false}, + {"main.main", false}, + } + for _, c := range cases { + if got := isSDKFrame(c.name); got != c.want { + t.Errorf("isSDKFrame(%q) = %v, want %v", c.name, got, c.want) + } + } +} + func TestSplitQualifiedFunction(t *testing.T) { cases := []struct{ in, lib, fn string }{ {"main.main()", "main", "main"}, diff --git a/transport.go b/transport.go index 65f05a8..b445752 100644 --- a/transport.go +++ b/transport.go @@ -2,6 +2,7 @@ package bt import ( "bytes" + "context" "errors" "fmt" "io" @@ -31,11 +32,14 @@ const rateLimitFallback = time.Minute const rateLimitMaxPause = 5 * time.Minute // httpTransport delivers serialized reports over HTTP. It is safe for -// concurrent use, applies the configured timeout, verifies response status, -// honors 429 Retry-After, and drains response bodies so connections are -// reused. +// concurrent use, applies the configured deadline through an SDK-owned +// request context (independent of any custom http.Client), verifies +// response status, honors 429 Retry-After, drains response bodies so +// connections are reused, and never lets a credential-bearing URL escape +// into an error or log line. type httpTransport struct { - client *http.Client + client *http.Client + timeout time.Duration mu sync.Mutex pauseUntil time.Time @@ -43,9 +47,15 @@ type httpTransport struct { func newHTTPTransport(client *http.Client, timeout time.Duration) *httpTransport { if client == nil { - client = &http.Client{Timeout: timeout} + client = &http.Client{} + } + return &httpTransport{client: client, timeout: timeout} +} + +func (t *httpTransport) closeIdleConnections() { + if t != nil && t.client != nil { + t.client.CloseIdleConnections() } - return &httpTransport{client: client} } // rateLimited reports whether submissions are currently paused. @@ -61,80 +71,134 @@ func (t *httpTransport) pause(d time.Duration) { t.pauseUntil = time.Now().Add(d) } -// maxAttachmentSize caps individual attachment uploads; larger files are -// skipped with a diagnostic. -const maxAttachmentSize = 10 << 20 // 10 MiB - // inlinePart is an in-memory attachment (e.g. the bt-breadcrumbs-0 file). type inlinePart struct { name string data []byte } -// send POSTs body to url. Attachments, when present, switch the request to -// the documented multipart form ("upload_file" part for the report JSON, -// "attachment_" parts for files). A non-2xx response is an error; a -// 429 additionally pauses future sends until the server's Retry-After -// deadline. -func (t *httpTransport) send(url string, body []byte, attachments []string, inline []inlinePart, d diag) error { +// send POSTs body to url under an SDK-owned deadline derived from parent. +// Attachments, when present, switch the request to the documented multipart +// form ("upload_file" part for the report JSON, "attachment_" parts +// for files). It returns the drop reason alongside any error. +func (t *httpTransport) send( + parent context.Context, + url string, + body []byte, + attachments []string, + inline []inlinePart, + limits multipartLimits, + d diag, +) (dropReason, error) { if t.rateLimited() { - return errRateLimited + return dropRateLimit, errRateLimited + } + if parent == nil { + parent = context.Background() } + ctx, cancel := context.WithTimeout(parent, t.timeout) + defer cancel() var ( reqBody io.Reader = bytes.NewReader(body) contentType = "application/json" ) if len(attachments) > 0 || len(inline) > 0 { - multipartBody, multipartType, err := buildMultipart(body, attachments, inline, d) + multipartBody, multipartType, err := buildMultipart(body, attachments, inline, limits, d) if err != nil { - return fmt.Errorf("bt: building multipart request: %w", err) + return dropSerialization, fmt.Errorf("bt: building multipart request: %w", err) } reqBody, contentType = multipartBody, multipartType } - req, err := http.NewRequest(http.MethodPost, url, reqBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, reqBody) if err != nil { - return fmt.Errorf("bt: building request: %w", err) + return dropSerialization, fmt.Errorf("bt: building request for %s: %s", + redactURL(url), sanitizeHTTPError(err, url)) } req.Header.Set("Content-Type", contentType) req.Header.Set("User-Agent", "backtrace-go/"+Version) resp, err := t.client.Do(req) if err != nil { - // *url.Error embeds the full URL (token included); scrub it - // before the error reaches any log. - var ue *neturl.Error - if errors.As(err, &ue) { - ue.URL = redactURL(ue.URL) - } - return fmt.Errorf("bt: sending report: %w", err) + // Never return an error that retains a credential-bearing URL. + return dropNetwork, fmt.Errorf("bt: sending report to %s: %s", + redactURL(url), sanitizeHTTPError(err, url)) } defer func() { - // Drain so the keep-alive connection can be reused. + // Drain (bounded) so the keep-alive connection can be reused. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) _ = resp.Body.Close() }() switch { case resp.StatusCode >= 200 && resp.StatusCode < 300: - return nil + return 0, nil case resp.StatusCode == http.StatusTooManyRequests: - d := retryAfter(resp) - t.pause(d) - return fmt.Errorf("bt: server rate limit (429), pausing submissions for %s", d) + pause := retryAfter(resp) + t.pause(pause) + return dropRateLimit, fmt.Errorf("bt: server rate limit (429), pausing submissions for %s", pause) default: - return fmt.Errorf("bt: server rejected report: %s", resp.Status) + return dropServerReject, fmt.Errorf("bt: server rejected report: %s", resp.Status) + } +} + +// sanitizeHTTPError renders err with every occurrence of the raw URL or its +// embedded credentials replaced. Returns a string (not an error) so callers +// cannot accidentally re-wrap the original. +func sanitizeHTTPError(err error, rawURL string) string { + if err == nil { + return "" + } + text := err.Error() + if rawURL != "" { + text = strings.ReplaceAll(text, rawURL, redactURL(rawURL)) + } + if u, parseErr := neturl.Parse(rawURL); parseErr == nil { + if token := u.Query().Get("token"); token != "" { + text = strings.ReplaceAll(text, token, "REDACTED") + } + segments := strings.Split(strings.Trim(u.Path, "/"), "/") + if pathEmbedsToken(u.Hostname(), segments) { + text = strings.ReplaceAll(text, segments[1], "REDACTED") + } + } + return text +} + +// maxMultipartNameLength bounds sanitized attachment part names. +const maxMultipartNameLength = 255 + +// safeMultipartName reduces an attachment path to a conservative printable +// basename for use in multipart part names and filenames. +func safeMultipartName(path string) string { + name := filepath.Base(path) + name = strings.Map(func(r rune) rune { + switch { + case r == '\r' || r == '\n' || r == 0 || r == '"' || r == '\\': + return '_' + case r < 0x20 || r == 0x7f: + return '_' + default: + return r + } + }, name) + if name == "" || name == "." || name == ".." || name == "/" || name == `\` { + return "attachment" + } + if len(name) > maxMultipartNameLength { + name = name[len(name)-maxMultipartNameLength:] } + return name } // buildMultipart assembles the multipart body documented for Backtrace // submissions: the report JSON in an "upload_file" part plus one -// "attachment_" part per readable attachment. Unreadable, -// non-regular, or oversized files are skipped, never failing the report -// itself. The size cap is enforced at read time (a file may grow between -// stat and copy). -func buildMultipart(body []byte, attachments []string, inline []inlinePart, d diag) (io.Reader, string, error) { +// "attachment_" part per admitted attachment. Unreadable, +// non-regular, oversized, or over-budget files — and files that fail while +// being read — are skipped, never failing the report itself. Size caps are +// enforced at read time (a file may grow between stat and copy). +func buildMultipart(body []byte, attachments []string, inline []inlinePart, limits multipartLimits, d diag) (io.Reader, string, error) { var buf bytes.Buffer w := multipart.NewWriter(&buf) @@ -158,30 +222,54 @@ func buildMultipart(body []byte, attachments []string, inline []inlinePart, d di } } + var ( + count int + totalBytes int64 + ) for _, path := range attachments { - info, err := os.Stat(path) - if err != nil { - d.logf("attachment %q skipped: %v", path, err) + if limits.maxAttachments < 0 { + d.logf("attachment %q skipped: attachments disabled (MaxAttachments < 0)", path) continue } - // FIFOs and devices would block or read unbounded data. - if !info.Mode().IsRegular() { - d.logf("attachment %q skipped: not a regular file", path) + if count >= limits.maxAttachments { + d.logf("attachment %q skipped: attachment count limit (%d) reached", + path, limits.maxAttachments) continue } - if info.Size() > maxAttachmentSize { - d.logf("attachment %q skipped: %d bytes exceeds the %d byte limit", - path, info.Size(), maxAttachmentSize) + remaining := limits.maxTotalBytes - totalBytes + if remaining <= 0 { + d.logf("attachment %q skipped: aggregate attachment budget (%d bytes) exhausted", + path, limits.maxTotalBytes) continue } + perFile := limits.maxAttachmentBytes + if perFile > remaining { + perFile = remaining + } + file, err := os.Open(path) if err != nil { d.logf("attachment %q skipped: %v", path, err) continue } - // Attachments from different directories may share a - // basename; uniquify so no part overwrites another. - name := filepath.Base(path) + // Stat the opened handle (not the path) so a file swapped + // between stat and open cannot bypass the checks. + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + file.Close() + d.logf("attachment %q skipped: not a readable regular file", path) + continue + } + if info.Size() > perFile { + file.Close() + d.logf("attachment %q skipped: %d bytes exceeds the %d-byte budget", + path, info.Size(), perFile) + continue + } + + // Attachments from different directories may share a basename; + // uniquify so no part overwrites another. + name := safeMultipartName(path) if used[name] { ext := filepath.Ext(name) stem := strings.TrimSuffix(name, ext) @@ -195,26 +283,31 @@ func buildMultipart(body []byte, attachments []string, inline []inlinePart, d di } used[name] = true - // Remember the buffer position so an over-limit file can be - // rolled back cleanly (part boundaries are only written by - // CreateFormFile/Close, so truncating to the mark removes the - // whole part). + // Remember the buffer position so a failed or over-limit read + // can be rolled back cleanly (part boundaries are only written + // by CreateFormFile/Close, so truncating removes the part). mark := buf.Len() part, err := w.CreateFormFile("attachment_"+name, name) var copied int64 if err == nil { - copied, err = io.Copy(part, io.LimitReader(file, maxAttachmentSize+1)) + copied, err = io.Copy(part, io.LimitReader(file, perFile+1)) } file.Close() if err != nil { - return nil, "", err + buf.Truncate(mark) + delete(used, name) + d.logf("attachment %q skipped: read failed: %v", path, err) + continue } - if copied > maxAttachmentSize { + if copied > perFile { buf.Truncate(mark) delete(used, name) - d.logf("attachment %q skipped: grew beyond the %d byte limit while reading", - path, maxAttachmentSize) + d.logf("attachment %q skipped: grew beyond the %d-byte budget while reading", + path, perFile) + continue } + count++ + totalBytes += copied } if err := w.Close(); err != nil { @@ -223,13 +316,16 @@ func buildMultipart(body []byte, attachments []string, inline []inlinePart, d di return &buf, w.FormDataContentType(), nil } -// redactURL hides the submission token in diagnostics output, both in the -// ?token= query form and in the submit.backtrace.io/{universe}/{token}/{fmt} +// redactURL hides credentials in diagnostics output: URL userinfo, the +// ?token= query form, and the submit-style {universe}/{token}/{format} // path form. func redactURL(u string) string { parsed, err := neturl.Parse(u) if err != nil { - return u + return "[REDACTED URL]" + } + if parsed.User != nil { + parsed.User = neturl.User("REDACTED") } q := parsed.Query() if q.Get("token") != "" { @@ -255,7 +351,7 @@ var hexTokenPattern = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) // path has the {universe}/{token}[/{format}] shape (known format suffix or // a hex token). Plain API paths like /api/post never match. func pathEmbedsToken(host string, segments []string) bool { - if len(segments) < 2 || len(segments) > 3 { + if len(segments) < 2 || len(segments) > 3 || segments[1] == "" { return false } if strings.EqualFold(host, "submit.backtrace.io") { diff --git a/transport_test.go b/transport_test.go index 0635946..ec3a008 100644 --- a/transport_test.go +++ b/transport_test.go @@ -1,6 +1,8 @@ package bt import ( + "context" + "errors" "net/http" "strings" "testing" @@ -52,8 +54,10 @@ func TestTransportPause(t *testing.T) { if !tr.rateLimited() { t.Error("pause not applied") } - if err := tr.send("http://127.0.0.1:1/unused", []byte("{}"), nil, nil, diag{}); err != errRateLimited { - t.Errorf("send while paused = %v, want errRateLimited", err) + reason, err := tr.send(context.Background(), "http://127.0.0.1:1/unused", + []byte("{}"), nil, nil, multipartLimits{}, diag{}) + if err != errRateLimited || reason != dropRateLimit { + t.Errorf("send while paused = (%v, %v), want (dropRateLimit, errRateLimited)", reason, err) } } @@ -87,4 +91,45 @@ func TestRedactURL(t *testing.T) { if got := redactURL("https://uni.sp.backtrace.io/api/post"); got != "https://uni.sp.backtrace.io/api/post" { t.Errorf("tokenless URL modified: %q", got) } + // URL userinfo is redacted. + if got := redactURL("https://user:secretpw@host/x"); strings.Contains(got, "secretpw") { + t.Errorf("userinfo not redacted: %q", got) + } + // Unparsable input degrades to a fixed placeholder, never the raw string. + if got := redactURL("http://%zz\x7f"); got != "[REDACTED URL]" { + t.Errorf("unparsable URL leaked: %q", got) + } +} + +func TestSanitizeHTTPError(t *testing.T) { + raw := "https://uni.sp.backtrace.io/post?format=json&token=supersecret" + err := errors.New(`Post "` + raw + `": context deadline exceeded`) + out := sanitizeHTTPError(err, raw) + if strings.Contains(out, "supersecret") { + t.Errorf("token leaked through sanitized error: %q", out) + } + if !strings.Contains(out, "context deadline exceeded") { + t.Errorf("error cause lost: %q", out) + } + + pathRaw := "https://submit.backtrace.io/universe/secrettoken123/json" + pathErr := errors.New(`Post "` + pathRaw + `": connection refused`) + if out := sanitizeHTTPError(pathErr, pathRaw); strings.Contains(out, "secrettoken123") { + t.Errorf("path token leaked through sanitized error: %q", out) + } +} + +func TestSafeMultipartName(t *testing.T) { + cases := []struct{ in, want string }{ + {"/var/log/app.log", "app.log"}, + {"/tmp/evil\r\nname", "evil__name"}, + {"/tmp/quote\"back\\slash", "quote_back_slash"}, + {"/", "attachment"}, + {".", "attachment"}, + } + for _, c := range cases { + if got := safeMultipartName(c.in); got != c.want { + t.Errorf("safeMultipartName(%q) = %q, want %q", c.in, got, c.want) + } + } } From 429f0feea6a8b579c61c0f1bee9d68a5b32ea804 Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 7 Aug 2026 17:16:53 -0400 Subject: [PATCH 09/13] fix(bcd): bounded token-safe uploads, timeout/lock correctness, platform parity --- bcd.go | 126 ++++++++++++++++++---- bcd_sys_unsupported.go | 11 +- bcd_trace_test.go | 50 ++++++++- put_options.go | 35 ++++++ tracer.go | 238 +++++++++++++++++++++++++++++------------ tracer_darwin_stub.go | 53 ++++----- tracer_test.go | 136 +++++++++++++++++++++++ 7 files changed, 529 insertions(+), 120 deletions(-) create mode 100644 put_options.go diff --git a/bcd.go b/bcd.go index d1e587a..dad27d6 100644 --- a/bcd.go +++ b/bcd.go @@ -84,14 +84,27 @@ func init() { SynchronousPut: true}} } -// Update global Tracer configuration. +// Update global Tracer configuration. Negative durations are treated as +// zero. func UpdateConfig(c GlobalConfig) { + if c.RateLimit < 0 { + c.RateLimit = 0 + } + state.m.Lock() defer state.m.Unlock() state.c = c } +// logfSafe shields lock-critical paths from panicking Log implementations: +// a diagnostic callback must never leak the global trace lock or kill a +// helper goroutine. +func logfSafe(l Log, level LogPriority, format string, v ...interface{}) { + defer func() { _ = recover() }() + l.Logf(level, format, v...) +} + // A generic out-of-process tracer interface. // // This is used primarily by the top-level functions of the bt package, @@ -298,8 +311,10 @@ func Register(t TracerSig) { t.Logf(LogDebug, "Registered tracer %s (signal set: %v)\n", t, ss) go func(t TracerSig) { + // Raw Logf is unsafe here: a panicking logger would kill this + // goroutine (and with it, signal handling) — or the process. for s := range c { - t.Logf(LogDebug, "Received %v; executing tracer\n", s) + logfSafe(t, LogDebug, "Received %v; executing tracer\n", s) _ = Trace(t, &signalError{s}, nil) @@ -313,13 +328,13 @@ func Register(t TracerSig) { continue } - t.Logf(LogDebug, "Resending %v to default handler\n", s) + logfSafe(t, LogDebug, "Resending %v to default handler\n", s) // Re-handle the signal with the default Go behavior. signal.Reset(s) p, err := os.FindProcess(os.Getpid()) if err != nil { - t.Logf(LogError, "Failed to resend signal: "+ + logfSafe(t, LogError, "Failed to resend signal: "+ "cannot find process object") return } @@ -327,7 +342,7 @@ func Register(t TracerSig) { _ = p.Signal(s) } - t.Logf(LogDebug, "Signal channel closed; exiting goroutine\n") + logfSafe(t, LogDebug, "Signal channel closed; exiting goroutine\n") }(t) } @@ -358,6 +373,11 @@ type tracerResult struct { err error } +// testHookBeforeTracerStart, when non-nil, runs in the exec goroutine +// immediately before Cmd.Start. Test-only: it makes the start-timeout +// handoff path deterministic. +var testHookBeforeTracerStart func() + // Executes the specified Tracer on the current process. // // If e is non-nil, it will be used to augment the trace according to the @@ -428,12 +448,16 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { // Report caller's goid var buf [64]byte n := runtime.Stack(buf[:], false) - idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0] - if goid, err := strconv.Atoi(idField); err == nil { - t.Logf(LogDebug, "Retrieved goid: %v\n", goid) - options = t.AddCallerGo(options, goid) + fields := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine ")) + if len(fields) > 0 { + if goid, err := strconv.Atoi(fields[0]); err == nil { + t.Logf(LogDebug, "Retrieved goid: %v\n", goid) + options = t.AddCallerGo(options, goid) + } else { + t.Logf(LogWarning, "Failed to retrieve goid: %v\n", err) + } } else { - t.Logf(LogWarning, "Failed to retrieve goid: %v\n", err) + t.Logf(LogWarning, "Failed to retrieve goid: empty stack header\n") } if e != nil { @@ -466,10 +490,15 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { // We now hold the trace lock. // Allow another tracer to execute (i.e. by re-populating the - // traceLock channel) as long as the current tracer has - // exited. + // traceLock channel) as long as the current tracer has exited. When a + // timeout fires before the subprocess start resolves, lock release is + // handed to a cleanup goroutine instead (see below), so a late child + // can never run concurrently with the next trace. + unlockHandedOff := false defer func() { - go traceUnlockRL(t, rl) + if !unlockHandedOff { + go traceUnlockRL(t, rl) + } }() done := make(chan tracerResult, 1) @@ -492,13 +521,16 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { defer traceOptions.SpawnedGs.Done() } - t.Logf(LogDebug, "Starting tracer %v\n", tracer) + logfSafe(t, LogDebug, "Starting tracer %v\n", tracer) var res tracerResult var stdOut bytes.Buffer tracer.Stdout = &stdOut + if testHookBeforeTracerStart != nil { + testHookBeforeTracerStart() + } if startErr := tracer.Start(); startErr != nil { res.err = startErr done <- res @@ -510,7 +542,7 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { res.stdOut = stdOut.Bytes() done <- res - t.Logf(LogDebug, "Tracer finished execution\n") + logfSafe(t, LogDebug, "Tracer finished execution\n") }() t.Logf(LogDebug, "Waiting for tracer completion...\n") @@ -555,6 +587,50 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { return } + default: + // Cmd.Start itself has not resolved by the deadline. + // Return to the caller now; a cleanup goroutine deals + // with the late child and only then releases the trace + // lock, so no other trace can run alongside it. + unlockHandedOff = true + go func() { + select { + case <-time.After(30 * time.Second): + // Cmd.Start itself is wedged (e.g. a + // pathologically slow exec). Give up and + // release the lock rather than disabling + // tracing forever. + logfSafe(t, LogError, + "Tracer Start did not resolve; releasing trace lock\n") + case <-started: + if killErr := tracer.Process.Kill(); killErr != nil && + !errors.Is(killErr, os.ErrProcessDone) { + logfSafe(t, LogError, + "Failed to kill late tracer: %v\n", killErr) + if kfPanic { + // Honors PanicOnKillFailure; in a + // goroutine this aborts the process, + // consistent with the option's intent. + panic(killErr) + } + } + // Bound the reap wait: an unkillable child must + // not hold the trace lock forever. + select { + case <-done: + case <-time.After(30 * time.Second): + logfSafe(t, LogError, + "Late tracer not reaped after kill; releasing trace lock\n") + } + case <-done: + } + traceUnlockRL(t, rl) + }() + + err = errors.New("Tracer start timed out") + logfSafe(t, LogError, "%v\n", err) + + return } case res = <-done: break @@ -576,16 +652,16 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { } putFn := func() error { - t.Logf(LogDebug, "Uploading snapshot...") + logfSafe(t, LogDebug, "Uploading snapshot...") if err := t.Put(res.stdOut); err != nil { - t.Logf(LogError, "Failed to upload snapshot: %s", + logfSafe(t, LogError, "Failed to upload snapshot: %s", err) return err } - t.Logf(LogDebug, "Successfully uploaded snapshot\n") + logfSafe(t, LogDebug, "Successfully uploaded snapshot\n") return nil } @@ -603,6 +679,9 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { if traceOptions.SpawnedGs != nil { defer traceOptions.SpawnedGs.Done() } + // A panicking Put/Log implementation must not kill the + // process from this goroutine. + defer func() { _ = recover() }() _ = putFn() }() @@ -614,10 +693,15 @@ func Trace(t Tracer, e error, traceOptions *TraceOptions) (err error) { } func traceUnlockRL(t Tracer, rl time.Duration) { - t.Logf(LogDebug, "Waiting for ratelimit (%v)\n", rl) + // The lock MUST be released even if a diagnostic callback panics; + // a consumed traceLock would disable tracing for the process + // lifetime. + defer func() { + traceLock <- struct{}{} + }() + logfSafe(t, LogDebug, "Waiting for ratelimit (%v)\n", rl) <-time.After(rl) - t.Logf(LogDebug, "Unlocking traceLock\n") - traceLock <- struct{}{} + logfSafe(t, LogDebug, "Unlocking traceLock\n") } // Create a unique error type to use during panic recovery. diff --git a/bcd_sys_unsupported.go b/bcd_sys_unsupported.go index 2a299ac..f25c3b7 100644 --- a/bcd_sys_unsupported.go +++ b/bcd_sys_unsupported.go @@ -3,17 +3,18 @@ package bt import ( - "errors" + "fmt" ) func gettid() (int, error) { - return 0, errors.New("Gettid() is unsupported on this system") + return 0, fmt.Errorf("%w: gettid", ErrUnsupportedPlatform) } // Call this function to allow other (non-parent) processes to trace this one. // -// This is a Linux-specific utility function and is stubbed out on other -// operating systems. +// This is a Linux-specific utility function; on other operating systems it +// returns an error wrapping ErrUnsupportedPlatform so callers can detect +// that tracing permissions were NOT changed. func EnableTracing() error { - return nil + return fmt.Errorf("%w: process tracing", ErrUnsupportedPlatform) } diff --git a/bcd_trace_test.go b/bcd_trace_test.go index 666b1db..04027da 100644 --- a/bcd_trace_test.go +++ b/bcd_trace_test.go @@ -107,9 +107,53 @@ func TestTraceNilFinalizeFailsGracefully(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "unavailable") { t.Fatalf("err = %v, want 'tracer unavailable' error", err) } - // The trace lock must have been released: a second call still works. - if err := Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}); err == nil { - t.Fatal("second Trace unexpectedly succeeded with nil Finalize") + // The trace lock must have been released: a second call must fail the + // SAME way (a lock-acquisition timeout would prove a leaked lock). + if err := Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}); err == nil || + !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("second Trace = %v, want 'tracer unavailable' (trace lock leaked?)", err) + } +} + +// TestTraceStartTimeoutHandoff pins the handoff path deterministically: the +// timeout fires while Cmd.Start is still blocked, Trace returns a start- +// timeout error, and the cleanup goroutine eventually releases the trace +// lock so later traces still run. +func TestTraceStartTimeoutHandoff(t *testing.T) { + defer traceTestConfig()() + + release := make(chan struct{}) + testHookBeforeTracerStart = func() { <-release } + defer func() { testHookBeforeTracerStart = nil }() + + tr := &fakeTracer{ + makeCmd: func() *exec.Cmd { return exec.Command("true") }, + dto: TraceOptions{Timeout: 5 * time.Second}, + } + + err := Trace(tr, nil, &TraceOptions{Timeout: 50 * time.Millisecond}) + if err == nil || !strings.Contains(err.Error(), "start timed out") { + t.Fatalf("err = %v, want start-timeout error", err) + } + + // Unblock the late Start; the cleanup goroutine must release the lock. + // The hook stays set (clearing it here would race with the blocked + // goroutine's earlier read); the closed channel makes it a no-op for + // subsequent traces, and the deferred clear is ordered by the lock + // handover. + close(release) + + lockFree := make(chan error, 1) + go func() { + lockFree <- Trace(tr, nil, &TraceOptions{Timeout: 5 * time.Second}) + }() + select { + case err := <-lockFree: + if err != nil { + t.Fatalf("post-handoff Trace = %v, want nil (lock released, tracer runs)", err) + } + case <-time.After(10 * time.Second): + t.Fatal("trace lock leaked by the start-timeout handoff") } } diff --git a/put_options.go b/put_options.go new file mode 100644 index 0000000..563de58 --- /dev/null +++ b/put_options.go @@ -0,0 +1,35 @@ +//go:build linux || freebsd || darwin + +package bt + +import ( + "net/http" + "time" +) + +// PutOptions modifies the behavior of snapshot uploads (Tracer.Put and +// friends). The zero value uses the documented defaults. +type PutOptions struct { + // If set to true, tracer results (i.e. generated snapshot files) + // will be unlinked from the filesystem after successful puts. + Unlink bool + + // Deprecated: use HTTPClient. Retained for source compatibility; + // ignored when HTTPClient is set. + Client http.Client + + // HTTPClient, when set, is used for snapshot uploads. Requests carry + // an SDK-owned deadline (Timeout) either way. + HTTPClient *http.Client + + // Timeout bounds each snapshot upload. Default: 30s. + Timeout time.Duration + + // MaxSnapshotBytes caps the size of an uploaded snapshot file. + // Default: 1 GiB. + MaxSnapshotBytes int64 + + // If set to true, tracer results will be uploaded after each + // successful Trace request. + OnTrace bool +} diff --git a/tracer.go b/tracer.go index b556466..f2830ea 100644 --- a/tracer.go +++ b/tracer.go @@ -4,6 +4,7 @@ package bt import ( "bytes" + "context" "errors" "fmt" "io" @@ -26,6 +27,14 @@ type pipes struct { stderr io.Writer } +const ( + defaultPutTimeout = 30 * time.Second + defaultMaxSnapshotBytes int64 = 1 << 30 + defaultTraceTimeout = 120 * time.Second +) + +// uploader is the connection information and options used during Put +// operations. type uploader struct { endpoint string options PutOptions @@ -70,6 +79,10 @@ type BTTracer struct { // Default trace options to use if none are specified to bt.Trace(). defaultTraceOptions TraceOptions + // Protects the uploader configuration: traces are documented + // goroutine-safe and may upload concurrently with ConfigurePut. + putMu sync.RWMutex + // The connection information and options used during Put operations. put uploader } @@ -159,7 +172,7 @@ func New(options NewOptions) *BTTracer { Faulted: true, CallerOnly: false, ErrClassification: true, - Timeout: time.Second * 120}} + Timeout: defaultTraceTimeout}} } const ( @@ -167,92 +180,132 @@ const ( defaultCoronerPort = "6098" ) -type PutOptions struct { - // If set to true, tracer results (i.e. generated snapshot files) - // will be unlinked from the filesystem after successful puts. - Unlink bool - - // The http.Client to use for uploading. The default will be used - // if left unspecified. - Client http.Client - - // If set to true, tracer results will be uploaded after each - // successful Trace request. - OnTrace bool -} - -// Configures the uploading of a generated snapshot file to a remote Backtrace -// coronerd object store. -// -// Uploads use simple one-shot semantics and won't retry on failures. For -// more robust snapshot uploading and directory monitoring, consider using -// coroner daemon, as described at -// https://documentation.backtrace.io/snapshot/#daemon. -// -// endpoint: The URL of the server. It must be a valid HTTP endpoint as -// according to url.Parse() (which is based on RFC 3986). The default scheme -// and port are https and 6098, respectively, and are used if left unspecified. -// -// token: The hash associated with the coronerd project to which this -// application belongs; see -// https://documentation.backtrace.io/coronerd_setup/#authentication-tokens -// for more details. -// -// options: Modifies behavior of the Put action; see PutOptions documentation -// for more details. -func (t *BTTracer) ConfigurePut(endpoint, token string, options PutOptions) error { +// buildPutURL validates the upload endpoint strictly and assembles the +// final URL. Only absolute http(s) URLs without userinfo, fragment, or +// opaque form are accepted; a missing scheme or port receives the coronerd +// defaults (IPv6-safe). +func buildPutURL(endpoint, token string) (string, error) { if endpoint == "" { - return errors.New("endpoint must be non-empty") + return "", errors.New("endpoint must be non-empty") } if token == "" { - return errors.New("token must be non-empty") + return "", errors.New("token must be non-empty") } u, err := url.Parse(endpoint) if err != nil { - return err + // Do not wrap err: *url.Error quotes the raw URL, and net/url + // inner errors can embed quoted input fragments too. The + // endpoint should not carry credentials (the token is a + // separate argument), but redact defensively anyway. + msg := "unparsable URL" + var ue *url.Error + if errors.As(err, &ue) && ue.Err != nil { + if inner := ue.Err.Error(); !strings.Contains(inner, `"`) { + msg = inner + } + } + return "", fmt.Errorf("invalid endpoint (%s): %s", msg, redactURL(endpoint)) } // Endpoints without the scheme prefix (or at the very least a '//` // prefix) are interpreted as remote server paths. Handle the - // (unlikely) case of an unspecified scheme. We won't allow other - // cases, like a port specified without a scheme, though, as per - // RFC 3986. - if u.Host == "" { - if u.Path == "" { - return errors.New("invalid URL specification: host " + - "or path must be non-empty") + // (unlikely) case of an unspecified scheme — but only for bare + // hosts: a path component in the shifted host would silently move + // the upload target. + if u.Host == "" && u.Scheme == "" && u.Opaque == "" && u.Path != "" { + host := strings.TrimSuffix(u.Path, "/") + if strings.Contains(host, "/") { + return "", errors.New("endpoint must be an absolute HTTP(S) URL " + + "or a bare host, got a scheme-less path") } - - u.Host = u.Path + u.Host = host u.Path = "" } - if u.Scheme == "" { u.Scheme = defaultCoronerScheme } + if u.Scheme != "http" && u.Scheme != "https" { + return "", fmt.Errorf("unsupported endpoint scheme %q", u.Scheme) + } + if u.Host == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" { + return "", errors.New("endpoint must be an absolute HTTP(S) URL " + + "without userinfo or fragment") + } - // Apply the default port IPv6-safely: Hostname() strips any - // brackets and JoinHostPort restores them as needed. - if _, _, portErr := net.SplitHostPort(u.Host); portErr != nil { + // Apply the default port IPv6-safely: Hostname() strips any brackets + // and JoinHostPort restores them as needed. Detect the missing-port + // case structurally and range-check explicit ports. + if host, port, portErr := net.SplitHostPort(u.Host); portErr != nil { + var addrErr *net.AddrError + if !errors.As(portErr, &addrErr) || !strings.Contains(addrErr.Err, "missing port") { + return "", fmt.Errorf("invalid endpoint host/port: %w", portErr) + } u.Host = net.JoinHostPort(u.Hostname(), defaultCoronerPort) + } else { + if n, convErr := strconv.Atoi(port); convErr != nil || n < 1 || n > 65535 { + return "", fmt.Errorf("invalid endpoint port %q", port) + } + _ = host } u.Path = "post" + u.RawPath = "" u.RawQuery = url.Values{"token": {token}}.Encode() + return u.String(), nil +} + +// ConfigurePut configures the uploading of a generated snapshot file to a +// remote Backtrace coronerd object store. +// +// Uploads use simple one-shot semantics and won't retry on failures. For +// more robust snapshot uploading and directory monitoring, consider using +// the coroner daemon. +// +// endpoint: the URL of the server; a valid HTTP(S) endpoint per url.Parse. +// The default scheme and port are https and 6098, used if left unspecified. +// +// token: the hash associated with the coronerd project to which this +// application belongs. +// +// options: modifies behavior of the Put action; see PutOptions. +func (t *BTTracer) ConfigurePut(endpoint, token string, options PutOptions) error { + putURL, err := buildPutURL(endpoint, token) + if err != nil { + return err + } + if options.Timeout < 0 { + return errors.New("upload timeout must be positive") + } + if options.Timeout == 0 { + options.Timeout = defaultPutTimeout + } + if options.MaxSnapshotBytes < 0 { + return errors.New("snapshot size limit must be positive") + } + if options.MaxSnapshotBytes == 0 { + options.MaxSnapshotBytes = defaultMaxSnapshotBytes + } + if options.HTTPClient == nil { + options.HTTPClient = &options.Client + } - t.put.endpoint = u.String() - t.put.options = options + t.putMu.Lock() + t.put = uploader{endpoint: putURL, options: options} + t.putMu.Unlock() + // Diagnostics carry the redacted URL only: the query embeds the token. t.Logf(LogDebug, "Put enabled (endpoint: %s, unlink: %v)\n", - t.put.endpoint, - t.put.options.Unlink) + redactURL(putURL), options.Unlink) return nil } // See bt.Tracer.PutOnTrace(). func (t *BTTracer) PutOnTrace() bool { + t.putMu.RLock() + defer t.putMu.RUnlock() + return t.put.options.OnTrace } @@ -307,22 +360,51 @@ func putDirWalk(t *BTTracer) filepath.WalkFunc { func (t *BTTracer) putSnapshotFile(path string) error { t.Logf(LogDebug, "Attempting to upload snapshot %s...\n", path) + t.putMu.RLock() + u := t.put + t.putMu.RUnlock() + if u.endpoint == "" || u.options.HTTPClient == nil { + return errors.New("snapshot upload is not configured") + } + body, err := os.Open(path) if err != nil { return err } defer body.Close() - // The file is automatically closed by the Post request after - // completion. - - resp, err := t.put.options.Client.Post( - t.put.endpoint, - "application/octet-stream", - body) + // Snapshot files must be bounded regular files: FIFOs or devices + // would block or stream unbounded data. + info, err := body.Stat() if err != nil { return err } + if !info.Mode().IsRegular() { + return errors.New("snapshot is not a regular file") + } + if info.Size() > u.options.MaxSnapshotBytes { + return fmt.Errorf("snapshot exceeds %d-byte limit", u.options.MaxSnapshotBytes) + } + + ctx, cancel := context.WithTimeout(context.Background(), u.options.Timeout) + defer cancel() + // Bound the body at read time too (the file may grow after stat) and + // declare the length so the request is not chunked-unbounded. + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.endpoint, + io.LimitReader(body, info.Size())) + if err != nil { + return fmt.Errorf("building upload request for %s: %s", + redactURL(u.endpoint), sanitizeHTTPError(err, u.endpoint)) + } + req.ContentLength = info.Size() + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := u.options.HTTPClient.Do(req) + if err != nil { + // Never surface an error retaining the credential-bearing URL. + return fmt.Errorf("upload to %s failed: %s", + redactURL(u.endpoint), sanitizeHTTPError(err, u.endpoint)) + } defer func() { // Drain (bounded) so the keep-alive connection can be reused // across PutDir loops. @@ -331,10 +413,10 @@ func (t *BTTracer) putSnapshotFile(path string) error { }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("failed to upload: %s", resp.Status) + return fmt.Errorf("upload to %s failed: %s", redactURL(u.endpoint), resp.Status) } - if t.put.options.Unlink { + if u.options.Unlink { t.Logf(LogDebug, "Unlinking snapshot...\n") if err := os.Remove(path); err != nil { @@ -483,9 +565,29 @@ func (t *BTTracer) ClearOptions() { t.options = nil } -// See bt.Tracer.DefaultTraceOptions(). +// See bt.Tracer.DefaultTraceOptions(). Returns a pointer to a COPY: mutate +// defaults through SetDefaultTraceOptions, not through the returned value. func (t *BTTracer) DefaultTraceOptions() *TraceOptions { - return &t.defaultTraceOptions + t.m.RLock() + defer t.m.RUnlock() + + opts := t.defaultTraceOptions + return &opts +} + +// SetDefaultTraceOptions replaces the defaults used by bt.Trace when no +// per-call options are supplied. A zero Timeout keeps the built-in default +// (a zero default would make every Trace time out instantly); use a +// negative Timeout to disable the deadline. +func (t *BTTracer) SetDefaultTraceOptions(opts TraceOptions) { + if opts.Timeout == 0 { + opts.Timeout = defaultTraceTimeout + } + + t.m.Lock() + defer t.m.Unlock() + + t.defaultTraceOptions = opts } // See bt.Tracer.Finalize(). @@ -516,7 +618,9 @@ func (t *BTTracer) Logf(level LogPriority, format string, v ...interface{}) { if logger != nil { // Called outside any BTTracer lock: format arguments may - // re-enter the tracer (e.g. %s on the tracer itself). + // re-enter the tracer (e.g. %s on the tracer itself). A + // panicking logger is contained. + defer func() { _ = recover() }() logger.Logf(level, format, v...) } } diff --git a/tracer_darwin_stub.go b/tracer_darwin_stub.go index e782d0b..b033dc9 100644 --- a/tracer_darwin_stub.go +++ b/tracer_darwin_stub.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "log" - "net/http" "os" "os/exec" "path/filepath" @@ -99,22 +98,10 @@ func New(options NewOptions) *BTTracer { } } -type PutOptions struct { - // If set to true, tracer results (i.e. generated snapshot files) - // will be unlinked from the filesystem after successful puts. - Unlink bool - - // The http.Client to use for uploading. The default will be used - // if left unspecified. - Client http.Client - - // If set to true, tracer results will be uploaded after each - // successful Trace request. - OnTrace bool -} - +// ConfigurePut is unsupported on macOS; the error wraps +// ErrUnsupportedPlatform so callers never mistake the no-op for success. func (t *BTTracer) ConfigurePut(endpoint, token string, options PutOptions) error { - return nil + return fmt.Errorf("%w: snapshot upload", ErrUnsupportedPlatform) } // See bt.Tracer.PutOnTrace(). @@ -122,14 +109,14 @@ func (t *BTTracer) PutOnTrace() bool { return t.put.options.OnTrace } -// See bt.Tracer.Put(). +// Put is unsupported on macOS. func (t *BTTracer) Put(snapshot []byte) error { - return nil + return fmt.Errorf("%w: snapshot upload", ErrUnsupportedPlatform) } -// Synchronously uploads snapshots contained in the specified directory. +// PutDir is unsupported on macOS. func (t *BTTracer) PutDir(path string) error { - return nil + return fmt.Errorf("%w: snapshot directory upload", ErrUnsupportedPlatform) } //nolint:all @@ -146,9 +133,9 @@ func (t *BTTracer) putSnapshotFile(path string) error { func (t *BTTracer) SetTracerPath(path string) { } -// Sets the output path for generated snapshots. +// SetOutputPath is unsupported on macOS. func (t *BTTracer) SetOutputPath(path string, perm os.FileMode) error { - return nil + return fmt.Errorf("%w: tracer output", ErrUnsupportedPlatform) } // Sets the input and output pipes for the tracer. @@ -204,9 +191,27 @@ func (t *BTTracer) Options() []string { func (t *BTTracer) ClearOptions() { } -// See bt.Tracer.DefaultTraceOptions(). +// See bt.Tracer.DefaultTraceOptions(). Returns a pointer to a COPY; use +// SetDefaultTraceOptions to change the defaults. func (t *BTTracer) DefaultTraceOptions() *TraceOptions { - return &t.defaultTraceOptions + t.m.RLock() + defer t.m.RUnlock() + + opts := t.defaultTraceOptions + return &opts +} + +// SetDefaultTraceOptions replaces the defaults used by bt.Trace when no +// per-call options are supplied. A zero Timeout keeps the built-in default. +func (t *BTTracer) SetDefaultTraceOptions(opts TraceOptions) { + if opts.Timeout == 0 { + opts.Timeout = 120 * time.Second + } + + t.m.Lock() + defer t.m.Unlock() + + t.defaultTraceOptions = opts } // See bt.Tracer.Finalize(). diff --git a/tracer_test.go b/tracer_test.go index 44cff4d..e8a95cb 100644 --- a/tracer_test.go +++ b/tracer_test.go @@ -5,9 +5,15 @@ package bt import ( "io" "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "sync" + "syscall" "testing" + "time" ) func TestConfigurePutURLForms(t *testing.T) { @@ -52,6 +58,136 @@ func TestConfigurePutURLForms(t *testing.T) { } } +func TestBuildPutURLRejections(t *testing.T) { + rejected := []struct{ name, endpoint string }{ + {"userinfo", "https://user:pw@host"}, + {"fragment", "https://host/x#frag"}, + {"non-http scheme", "ftp://host"}, + {"opaque", "mailto:x@y"}, + {"out-of-range port", "https://host:99999"}, + {"non-numeric port", "https://host:abc"}, + } + for _, c := range rejected { + t.Run(c.name, func(t *testing.T) { + if _, err := buildPutURL(c.endpoint, "tok"); err == nil { + t.Errorf("buildPutURL(%q) accepted", c.endpoint) + } + }) + } + + // The parse-failure error must not leak the raw endpoint verbatim. + if _, err := buildPutURL("https://host/x\x7f?token=SECRETLEAK", "tok"); err == nil { + t.Error("control-character endpoint accepted") + } else if strings.Contains(err.Error(), "SECRETLEAK") { + t.Errorf("raw endpoint leaked through parse error: %v", err) + } +} + +func TestConfigurePutOptionValidation(t *testing.T) { + tr := New(NewOptions{}) + if err := tr.ConfigurePut("https://host", "tok", PutOptions{Timeout: -time.Second}); err == nil { + t.Error("negative upload timeout accepted") + } + if err := tr.ConfigurePut("https://host", "tok", PutOptions{MaxSnapshotBytes: -1}); err == nil { + t.Error("negative snapshot size limit accepted") + } +} + +// TestDefaultTraceOptionsCopySemantics pins the race fix: the returned +// pointer is a copy, and SetDefaultTraceOptions is the mutation path. +func TestDefaultTraceOptionsCopySemantics(t *testing.T) { + tr := New(NewOptions{}) + opts := tr.DefaultTraceOptions() + opts.Timeout = time.Nanosecond // must NOT affect the tracer's defaults + + if got := tr.DefaultTraceOptions().Timeout; got != 120*time.Second { + t.Errorf("defaults mutated through returned pointer: Timeout = %v", got) + } + + tr.SetDefaultTraceOptions(TraceOptions{Timeout: 7 * time.Second}) + if got := tr.DefaultTraceOptions().Timeout; got != 7*time.Second { + t.Errorf("SetDefaultTraceOptions not applied: Timeout = %v", got) + } +} + +func TestPutSnapshotFileHardening(t *testing.T) { + dir := t.TempDir() + snapshot := filepath.Join(dir, "snap.btt") + if err := os.WriteFile(snapshot, []byte("snapshot-bytes"), 0o644); err != nil { + t.Fatal(err) + } + + t.Run("unconfigured", func(t *testing.T) { + tr := New(NewOptions{}) + if err := tr.putSnapshotFile(snapshot); err == nil || + !strings.Contains(err.Error(), "not configured") { + t.Errorf("err = %v, want not-configured error", err) + } + }) + + t.Run("non-regular file rejected", func(t *testing.T) { + fifo := filepath.Join(dir, "snap.fifo") + if err := syscall.Mkfifo(fifo, 0o644); err != nil { + t.Skipf("mkfifo unavailable: %v", err) + } + // Keep the FIFO openable without blocking: open the write end. + w, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0) + if err == nil { + defer w.Close() + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("non-regular file reached the server") + })) + defer srv.Close() + tr := New(NewOptions{}) + if err := tr.ConfigurePut(srv.URL, "tok", PutOptions{}); err != nil { + t.Fatal(err) + } + if err := tr.putSnapshotFile(fifo); err == nil || + !strings.Contains(err.Error(), "regular file") { + t.Errorf("err = %v, want regular-file rejection", err) + } + }) + + t.Run("size cap enforced", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("oversized snapshot reached the server") + })) + defer srv.Close() + tr := New(NewOptions{}) + if err := tr.ConfigurePut(srv.URL, "tok", PutOptions{MaxSnapshotBytes: 4}); err != nil { + t.Fatal(err) + } + if err := tr.putSnapshotFile(snapshot); err == nil || + !strings.Contains(err.Error(), "limit") { + t.Errorf("err = %v, want size-limit rejection", err) + } + }) + + t.Run("success with bounded body and token-safe endpoint", func(t *testing.T) { + var gotLen int64 + var gotToken string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotLen = int64(len(body)) + gotToken = r.URL.Query().Get("token") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + tr := New(NewOptions{}) + if err := tr.ConfigurePut(srv.URL, "tok", PutOptions{}); err != nil { + t.Fatal(err) + } + if err := tr.putSnapshotFile(snapshot); err != nil { + t.Fatalf("putSnapshotFile: %v", err) + } + if gotLen != int64(len("snapshot-bytes")) || gotToken != "tok" { + t.Errorf("upload = %d bytes, token %q", gotLen, gotToken) + } + }) +} + // TestTracerLoggerConcurrency locks in the fix for the recursive-RLock // deadlock: Finalize/Logf/String must be callable concurrently with // SetLogger/SetLogLevel. Run with -race; a regression deadlocks or races. From f81bcbe8d1302a5b3242d88a1e42fe2137612bad Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 7 Aug 2026 17:17:29 -0400 Subject: [PATCH 10/13] fix(bthttp): 500 on swallowed panics, opt-in request PII --- bthttp/bthttp.go | 186 ++++++++++++++++++++++++++++++++++++------ bthttp/bthttp_test.go | 127 ++++++++++++++++++++++++++-- 2 files changed, 280 insertions(+), 33 deletions(-) diff --git a/bthttp/bthttp.go b/bthttp/bthttp.go index 8cf2593..b7c1513 100644 --- a/bthttp/bthttp.go +++ b/bthttp/bthttp.go @@ -1,40 +1,71 @@ // Package bthttp provides net/http middleware that reports panics in HTTP -// handlers to Backtrace, enriched with request attributes. +// handlers to Backtrace. // -// handler := bthttp.New(bthttp.Options{Repanic: true}).Handle(mux) -// http.ListenAndServe(":8080", handler) +// handler := bthttp.New(bthttp.Options{}) +// http.ListenAndServe(":8080", handler.Handle(mux)) // // Reports are sent through the client configured via bt.Options, or through -// Options.Client when set. +// Options.Client when set. By default only non-identifying request metadata +// (method, protocol, registered route pattern) is attached; raw URL, host, +// remote address, and user agent are gated behind Options.SendDefaultPII. +// +// The middleware wraps the http.ResponseWriter (to detect uncommitted +// responses). The wrapper implements Flush, Hijack, Push, ReadFrom, and +// Unwrap; handlers that type-assert the writer to a concrete type should +// use http.NewResponseController or Unwrap instead. package bthttp import ( + "bufio" + "io" + "net" "net/http" "time" bt "github.com/backtrace-labs/backtrace-go" ) +// Attribute length bounds; attacker-controlled request fields are truncated. +const ( + maxRouteLength = 2048 + maxURLLength = 4096 + maxHostLength = 1024 + maxRemoteLength = 1024 + maxUserAgentLength = 2048 +) + // Options configures the middleware. type Options struct { // Repanic re-raises the panic after reporting so outer middleware or // the net/http server recovery can run. When false the panic is - // swallowed and the connection is left to net/http's default - // handling of an aborted handler. + // swallowed and, if the handler had not committed a response, the + // middleware writes 500 Internal Server Error. Repanic bool // WaitForDelivery blocks the failing request until the report is - // delivered (bounded by FlushTimeout) instead of returning + // delivered, bounded by FlushTimeout, instead of returning // immediately. Recommended when Repanic is true and the process may // terminate. WaitForDelivery bool - // FlushTimeout bounds WaitForDelivery. Default: 2s. + // FlushTimeout bounds WaitForDelivery (one deadline covering both + // capture and delivery). Default: 2s. FlushTimeout time.Duration // Client sends reports through a specific bt.Client instead of the // global reporter. Client *bt.Client + + // SendDefaultPII includes the raw request path, host, remote address, + // and user agent with panic reports. Default false: these fields can + // carry personal data, tenant identifiers, or session-bearing path + // components. + SendDefaultPII bool + + // RequestAttributes, when set, contributes application-approved + // request metadata to panic reports. The returned map is copied by + // the SDK; a panic inside the callback is contained and ignored. + RequestAttributes func(*http.Request) map[string]interface{} } // Handler wraps HTTP handlers with panic reporting. @@ -53,47 +84,154 @@ func New(opts Options) *Handler { // Handle wraps next with panic reporting. func (h *Handler) Handle(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer h.recoverAndReport(r) - next.ServeHTTP(w, r) + tw := &trackingWriter{ResponseWriter: w} + defer h.recoverAndReport(tw, r) + next.ServeHTTP(tw, r) }) } // HandleFunc wraps next with panic reporting. func (h *Handler) HandleFunc(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - defer h.recoverAndReport(r) - next(w, r) + tw := &trackingWriter{ResponseWriter: w} + defer h.recoverAndReport(tw, r) + next(tw, r) } } -func (h *Handler) recoverAndReport(r *http.Request) { +func (h *Handler) recoverAndReport(w *trackingWriter, r *http.Request) { v := recover() if v == nil { return } attrs := map[string]interface{}{ - "request.url": r.URL.Path, - "request.method": r.Method, - "request.host": r.Host, - "request.remote_addr": r.RemoteAddr, - "request.user_agent": r.UserAgent(), - "request.proto": r.Proto, + "request.method": r.Method, + "request.proto": r.Proto, + } + if r.Pattern != "" { + attrs["request.route"] = boundedString(r.Pattern, maxRouteLength) + } + if h.opts.SendDefaultPII { + attrs["request.url"] = boundedString(r.URL.Path, maxURLLength) + attrs["request.host"] = boundedString(r.Host, maxHostLength) + attrs["request.remote_addr"] = boundedString(r.RemoteAddr, maxRemoteLength) + attrs["request.user_agent"] = boundedString(r.UserAgent(), maxUserAgentLength) + } + for k, value := range h.safeRequestAttributes(r) { + attrs[k] = value } if h.opts.Client != nil { - h.opts.Client.ReportPanicValue(v, attrs) if h.opts.WaitForDelivery { - h.opts.Client.Flush(h.opts.FlushTimeout) + h.opts.Client.ReportPanicValueAndFlush(v, attrs, h.opts.FlushTimeout) + } else { + h.opts.Client.ReportPanicValue(v, attrs) } + } else if h.opts.WaitForDelivery { + bt.ReportPanicValueAndFlush(v, attrs, h.opts.FlushTimeout) } else { bt.ReportPanicValue(v, attrs) - if h.opts.WaitForDelivery { - bt.Flush(h.opts.FlushTimeout) - } } if h.opts.Repanic { panic(v) } + // Swallowing the panic means net/http's default handling never runs; + // an uncommitted response would otherwise become an empty 200 OK. + if !w.wroteHeader { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } +} + +// safeRequestAttributes runs the user callback behind panic containment. +func (h *Handler) safeRequestAttributes(r *http.Request) (out map[string]interface{}) { + if h.opts.RequestAttributes == nil { + return nil + } + defer func() { _ = recover() }() + produced := h.opts.RequestAttributes(r) + if produced == nil { + return nil + } + out = make(map[string]interface{}, len(produced)) + for k, v := range produced { + out[k] = v + } + return out +} + +func boundedString(s string, max int) string { + if len(s) > max { + return s[:max] + } + return s +} + +// trackingWriter records whether a response was committed so a swallowed +// panic can be converted into a 500 when nothing was written. It preserves +// the optional ResponseWriter interfaces via http.NewResponseController +// (Flush/Hijack) and direct assertions (Push/ReadFrom), and exposes Unwrap +// for the controller. +type trackingWriter struct { + http.ResponseWriter + wroteHeader bool + status int +} + +func (w *trackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *trackingWriter) WriteHeader(code int) { + if w.wroteHeader { + return + } + w.wroteHeader = true + w.status = code + w.ResponseWriter.WriteHeader(code) +} + +func (w *trackingWriter) Write(p []byte) (int, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(p) +} + +func (w *trackingWriter) Flush() { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + _ = http.NewResponseController(w.ResponseWriter).Flush() +} + +func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return http.NewResponseController(w.ResponseWriter).Hijack() +} + +func (w *trackingWriter) Push(target string, opts *http.PushOptions) error { + if p, ok := w.ResponseWriter.(http.Pusher); ok { + return p.Push(target, opts) + } + return http.ErrNotSupported +} + +func (w *trackingWriter) ReadFrom(r io.Reader) (int64, error) { + if !w.wroteHeader { + w.WriteHeader(http.StatusOK) + } + if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok { + return rf.ReadFrom(r) + } + return io.Copy(w.ResponseWriter, r) +} + +// CloseNotify delegates to the underlying writer for handlers still using +// the deprecated http.CloseNotifier interface. +// +//nolint:staticcheck // deliberate passthrough of the deprecated interface +func (w *trackingWriter) CloseNotify() <-chan bool { + if cn, ok := w.ResponseWriter.(http.CloseNotifier); ok { + return cn.CloseNotify() + } + return nil } diff --git a/bthttp/bthttp_test.go b/bthttp/bthttp_test.go index 4e4370f..f5b419f 100644 --- a/bthttp/bthttp_test.go +++ b/bthttp/bthttp_test.go @@ -32,6 +32,15 @@ func (c *capture) last() map[string]interface{} { return c.payloads[len(c.payloads)-1] } +func lastAttrs(c *capture) map[string]interface{} { + p := c.last() + if p == nil { + return nil + } + attrs, _ := p["attributes"].(map[string]interface{}) + return attrs +} + func newCaptureClient(t *testing.T) (*bt.Client, *capture) { t.Helper() cap := &capture{} @@ -55,33 +64,116 @@ func newCaptureClient(t *testing.T) (*bt.Client, *capture) { return client, cap } -func TestMiddlewareReportsPanicsWithRequestAttributes(t *testing.T) { +func TestMiddlewareReportsPanics(t *testing.T) { client, cap := newCaptureClient(t) h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second}) - wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc("POST /checkout", func(w http.ResponseWriter, r *http.Request) { panic("handler exploded") - })) + }) + wrapped := h.Handle(mux) req := httptest.NewRequest(http.MethodPost, "/checkout?item=1", nil) req.Header.Set("User-Agent", "bthttp-test-agent") - wrapped.ServeHTTP(httptest.NewRecorder(), req) + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, req) if cap.count() != 1 { t.Fatalf("reports = %d, want 1", cap.count()) } - attrs, _ := cap.last()["attributes"].(map[string]interface{}) + attrs := lastAttrs(cap) if attrs["error.message"] != "handler exploded" { t.Errorf("error.message = %v", attrs["error.message"]) } - if attrs["request.url"] != "/checkout" || attrs["request.method"] != "POST" { - t.Errorf("request attributes wrong: url=%v method=%v", attrs["request.url"], attrs["request.method"]) + if attrs["report_type"] != "panic" { + t.Errorf("report_type = %v", attrs["report_type"]) + } + if attrs["request.method"] != "POST" { + t.Errorf("request.method = %v", attrs["request.method"]) + } + if attrs["request.route"] != "POST /checkout" { + t.Errorf("request.route = %v", attrs["request.route"]) + } + + // PII defaults OFF: raw URL, host, remote address, user agent absent. + for _, key := range []string{"request.url", "request.host", "request.remote_addr", "request.user_agent"} { + if _, present := attrs[key]; present { + t.Errorf("%s sent without SendDefaultPII", key) + } + } + + // A swallowed panic on an uncommitted response becomes a 500, not an + // empty 200. + if rec.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rec.Code) + } +} + +func TestMiddlewareSendDefaultPII(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second, + SendDefaultPII: true}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("with pii") + })) + + req := httptest.NewRequest(http.MethodGet, "/orders?id=1", nil) + req.Header.Set("User-Agent", "bthttp-test-agent") + wrapped.ServeHTTP(httptest.NewRecorder(), req) + + attrs := lastAttrs(cap) + if attrs["request.url"] != "/orders" { + t.Errorf("request.url = %v", attrs["request.url"]) } if attrs["request.user_agent"] != "bthttp-test-agent" { t.Errorf("request.user_agent = %v", attrs["request.user_agent"]) } - if attrs["report_type"] != "panic" { - t.Errorf("report_type = %v", attrs["report_type"]) + if attrs["request.host"] == nil || attrs["request.remote_addr"] == nil { + t.Errorf("host/remote_addr missing with SendDefaultPII: %v", attrs) + } +} + +func TestMiddlewareRequestAttributesCallback(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second, + RequestAttributes: func(r *http.Request) map[string]interface{} { + return map[string]interface{}{"tenant.id": r.Header.Get("X-Tenant")} + }}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("with callback") + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Tenant", "acme") + wrapped.ServeHTTP(httptest.NewRecorder(), req) + + if attrs := lastAttrs(cap); attrs["tenant.id"] != "acme" { + t.Errorf("callback attribute missing: %v", attrs["tenant.id"]) + } +} + +func TestMiddlewarePanickingCallbackIsContained(t *testing.T) { + client, cap := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second, + RequestAttributes: func(r *http.Request) map[string]interface{} { + panic("callback bug") + }}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("handler panic") + })) + + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) // must not panic + + if cap.count() != 1 { + t.Fatalf("report lost to callback panic: %d", cap.count()) + } + if rec.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rec.Code) } } @@ -107,6 +199,23 @@ func TestMiddlewareNoPanicPassthrough(t *testing.T) { } } +func TestMiddlewareCommittedResponseKeptOnPanic(t *testing.T) { + client, _ := newCaptureClient(t) + + h := New(Options{Client: client, WaitForDelivery: true, FlushTimeout: 5 * time.Second}) + wrapped := h.Handle(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) // response committed... + panic("late panic") // ...then the handler dies + })) + + rec := httptest.NewRecorder() + wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + if rec.Code != http.StatusAccepted { + t.Errorf("status = %d; committed response must not be overwritten", rec.Code) + } +} + func TestMiddlewareRepanic(t *testing.T) { client, cap := newCaptureClient(t) From 6061049589fd4cc43fa58fa66c3f16c48084316b Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 7 Aug 2026 17:19:14 -0400 Subject: [PATCH 11/13] docs,ci: update docs and ci workflow --- .github/workflows/tests.yml | 44 ++++++++++++++++++++-- README.md | 45 ++++++++++++++++------ stats.go | 75 +++++++++++++++++++++++++++++++++++++ symlink_verify_test.go | 36 ++++++++++++++++++ 4 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 stats.go create mode 100644 symlink_verify_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f42435..c2d49bf 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,22 +13,27 @@ jobs: lint: name: lint runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.26.x' - name: Check gofmt run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then echo "gofmt required for:"; echo "$unformatted"; exit 1 fi + - name: Verify go.mod/go.sum are tidy + run: | + go mod tidy + git diff --exit-code go.mod go.sum - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: - version: latest + version: v2.12.2 test: name: test (go ${{ matrix.go-version }}, ${{ matrix.os }}) @@ -40,6 +45,7 @@ jobs: go-version: ['1.25.x', '1.26.x'] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Set up Go @@ -51,9 +57,41 @@ jobs: - name: Run unit tests (race detector) run: go test -race -count=1 ./... + govulncheck: + name: govulncheck + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + - name: Scan for known vulnerabilities + run: | + go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + govulncheck ./... + + fuzz-smoke: + name: fuzz (smoke) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.x' + - name: Fuzz parsers and redaction briefly + run: | + for target in FuzzBuildThreads FuzzRedactURL FuzzRetryAfter FuzzSplitQualifiedFunction FuzzSafeMultipartName; do + go test -run "^$target$" -fuzz "^$target$" -fuzztime 20s . + done + cross-compile: name: build (${{ matrix.target }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -69,7 +107,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.26.x' - name: Build run: | export GOOS="${TARGET%/*}" GOARCH="${TARGET#*/}" diff --git a/README.md b/README.md index 20ad3da..565d107 100644 --- a/README.md +++ b/README.md @@ -55,26 +55,31 @@ client.ReportMessage("cache warmup skipped", nil) // plain message client.ReportPanicValue(recovered, nil) // recovered panic value // Panic capture with defer: -defer bt.ReportPanic(nil) // reports, flushes, re-panics -defer bt.ReportAndRecoverPanic(nil) // reports and swallows the panic +defer bt.ReportPanic(nil) // reports, waits (one 5s deadline), re-panics +defer bt.ReportAndRecoverPanic(nil) // reports without waiting; swallows the panic ``` -Reporting never blocks the caller on network I/O: reports are queued to a background worker, and when the queue is full new reports are dropped and counted (`client.DroppedReports()`) instead of stalling the application. +Regular `Report*` calls never block on network I/O or queue admission: reports are queued to a background worker, and a full queue drops the newest report and counts it (`client.Stats()`, `client.DroppedReports()`) instead of stalling the application. The only synchronous waits are the explicitly bounded ones: `Flush`, `Close`, `ReportPanicValueAndFlush`, and `bt.ReportPanic` (one 5s deadline); `ReportAndRecoverPanic` reports without waiting. Delivery lifecycle: ```go -client.Flush(5 * time.Second) // wait for queued reports; client stays usable -client.Close() // drain, stop the worker, release the client +client.Flush(5 * time.Second) // wait for reports queued before the call; client stays usable +client.FlushContext(ctx) // context-aware variant +client.Close() // drain and stop, bounded by ShutdownTimeout (default 5s) +client.CloseContext(ctx) // explicit deadline; cancels in-flight sends when it expires ``` +Flushing proves local processing and send completion, not backend acceptance. While the SDK is unconfigured, `bt.ReportPanic`/`bt.ReportAndRecoverPanic` do nothing — in particular they do not recover, so the application's panic proceeds unchanged. + ## Configuration ```go client, err := bt.NewClient(bt.Config{ Endpoint: "https://submit.backtrace.io/{universe}/{token}/json", CaptureAllGoroutines: true, // include every goroutine's stack - SourceCode: bt.SourceCodeContext, // context lines (default), File, or None + SourceCode: bt.SourceCodeContext, // default: Metadata (no source text); Context/File are opt-in + SourceRoots: []string{"/srv/app"}, // restrict which files may be read for snippets ContextLineCount: 8, // lines above/below each frame Attributes: map[string]interface{}{ // stamped on every report "application.environment": "production", @@ -102,7 +107,7 @@ client.AddBreadcrumb(bt.Breadcrumb{ }) ``` -Every report automatically includes: hostname, process ID and age, Go version, goroutine count, heap statistics, GC count, CPU architecture and model, OS version, machine GUID, `application.version` / `vcs.revision` (from Go build info), the Go module dependency list, and — on Linux — `/proc` memory and scheduler attributes. +Every report automatically includes: hostname, process ID and age, Go version, goroutine count, heap statistics, GC count, CPU architecture and model, OS version, `application.version` / `vcs.revision` (from Go build info), the Go module dependency list, and — on Linux — `/proc` memory and scheduler attributes. A stable machine identifier (`guid`) is opt-in via `SendMachineID`. Machine metadata is gathered once per process with native file, syscall, and registry reads — no shell pipelines (macOS additionally runs a single time-bounded `ioreg` probe for the opt-in machine identifier). ### net/http middleware @@ -112,13 +117,18 @@ import "github.com/backtrace-labs/backtrace-go/bthttp" handler := bthttp.New(bthttp.Options{ Client: client, // omit to use the global reporter Repanic: false, // re-raise after reporting - WaitForDelivery: true, // block the failing request until delivered + WaitForDelivery: true, // block the failing request until delivered (bounded by FlushTimeout) + SendDefaultPII: false, // raw path/host/remote addr/user agent are OPT-IN }) http.ListenAndServe(":8080", handler.Handle(mux)) ``` -Panics in handlers are reported with `request.url`, `request.method`, -`request.remote_addr`, and `request.user_agent` attributes. +By default panic reports carry `request.method`, `request.proto`, and the +registered route pattern (`request.route`). Raw URL path, host, remote +address, and user agent require `SendDefaultPII: true`; use +`RequestAttributes` to contribute application-approved metadata. When a +swallowed panic left the response uncommitted, the middleware writes +`500 Internal Server Error` instead of an empty `200`. ## Legacy global API @@ -142,7 +152,7 @@ Notes: - Configure `bt.Options` before the first report. For attribute changes at runtime use `bt.SetAttribute` / `bt.SetAttributes`, which are safe for concurrent use. - `bt.FinishSendingReports()` now waits for queued reports **without** stopping the reporter (historically it killed the sender permanently): prefer `bt.Flush(timeout)`. -- Source capture now defaults to context lines around each frame instead of whole files: opt back in with `Options.SourceCode = bt.SourceCodeFile`. +- Source capture now defaults to path/line metadata only — no source text leaves the host. Opt in with `Options.SourceCode = bt.SourceCodeContext` (snippets) or `bt.SourceCodeFile` (whole files), optionally constrained by `Options.SourceRoots`. - The reporting API (`Client` methods, `bt.Report`, `bt.ReportPanic`, ...) never panics; `DebugBacktrace` only controls diagnostic logging. (The bcd tracing integration may panic on tracer kill failure unless `GlobalConfig.PanicOnKillFailure` is disabled via `bt.UpdateConfig`.) ## Thread-safety contract @@ -152,6 +162,19 @@ Notes: `Options` struct and `Config` maps are read when reports are captured; mutate them only before reporting starts (or via `SetAttribute`). +## Migration and security + +- [MIGRATION.md](MIGRATION.md) — upgrading from pre-1.1.0 (Go floor, source + capture opt-in, panic-helper semantics, shutdown behavior). +- [SECURITY.md](SECURITY.md) — private vulnerability reporting. + +## Scope + +This SDK reports errors, messages, and recovered panics from within the +process. Crashes that bypass Go panics (native/cgo faults, runtime aborts) +require out-of-process capture: use the bcd tracer integration below, or +the Backtrace Coresnap workflow, for robust fatal-crash coverage. + ## bcd (out-of-process tracing) The `bt` package also provides integration with out-of-process tracers. diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..996886c --- /dev/null +++ b/stats.go @@ -0,0 +1,75 @@ +package bt + +import "sync/atomic" + +// dropReason indexes internalStats.drops. Keep dropReasonCount last. +type dropReason int + +const ( + dropSampled dropReason = iota + dropQueueFull + dropClosed + dropBeforeSend + dropSerialization + dropOversize + dropRateLimit + dropNetwork + dropServerReject + dropInternal + dropReasonCount +) + +// internalStats backs the ClientStats snapshot with lock-free counters. +type internalStats struct { + accepted atomic.Uint64 + delivered atomic.Uint64 + drops [dropReasonCount]atomic.Uint64 +} + +func (s *internalStats) drop(r dropReason) { + s.drops[r].Add(1) +} + +func (s *internalStats) droppedTotal() uint64 { + var total uint64 + for i := range s.drops { + total += s.drops[i].Load() + } + return total +} + +// ClientStats is an immutable snapshot of a client's report accounting, +// broken down by outcome. Accepted counts reports admitted to the queue; +// Delivered counts successful submissions; the remaining fields count +// discarded reports by reason. +type ClientStats struct { + Accepted uint64 + Delivered uint64 + Sampled uint64 + QueueFull uint64 + Closed uint64 + BeforeSend uint64 + Serialization uint64 + Oversize uint64 + RateLimited uint64 + Network uint64 + ServerReject uint64 + Internal uint64 +} + +func (s *internalStats) snapshot() ClientStats { + return ClientStats{ + Accepted: s.accepted.Load(), + Delivered: s.delivered.Load(), + Sampled: s.drops[dropSampled].Load(), + QueueFull: s.drops[dropQueueFull].Load(), + Closed: s.drops[dropClosed].Load(), + BeforeSend: s.drops[dropBeforeSend].Load(), + Serialization: s.drops[dropSerialization].Load(), + Oversize: s.drops[dropOversize].Load(), + RateLimited: s.drops[dropRateLimit].Load(), + Network: s.drops[dropNetwork].Load(), + ServerReject: s.drops[dropServerReject].Load(), + Internal: s.drops[dropInternal].Load(), + } +} diff --git a/symlink_verify_test.go b/symlink_verify_test.go new file mode 100644 index 0000000..2608166 --- /dev/null +++ b/symlink_verify_test.go @@ -0,0 +1,36 @@ +package bt + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSymlinkBypassVerify(t *testing.T) { + allowedDir := t.TempDir() + secretDir := t.TempDir() + secret := filepath.Join(secretDir, "secret.go") + if err := os.WriteFile(secret, []byte("TOP\nSECRET\nDATA\n"), 0o644); err != nil { + t.Fatal(err) + } + // symlink under allowed root pointing to secret outside it + link := filepath.Join(allowedDir, "linked.go") + if err := os.Symlink(secret, link); err != nil { + t.Fatal(err) + } + + stack := "goroutine 1 [running]:\n" + + "main.a()\n" + + "\t" + link + ":2 +0x1\n" + + threads, sources, _ := buildThreads([]byte(stack), sourceOptions{ + mode: SourceCodeContext, contextLines: 2, tabWidth: 8, + roots: []string{allowedDir}, + }) + frames := threads["0"].Stacks + got := sources[frames[0].SourceCodeID].Text + t.Logf("embedded text via symlink: %q", got) + if got != "" { + t.Errorf("BYPASS CONFIRMED: symlink leaked target content: %q", got) + } +} From 0fc7b241f35f79cd89b54905bc617740f8ca30bd Mon Sep 17 00:00:00 2001 From: melekr Date: Fri, 7 Aug 2026 22:03:16 -0400 Subject: [PATCH 12/13] fix(ci): fix linux/windows tests --- fuzz_test.go | 26 +++++++++++++++++++------- tracer_test.go | 15 +++------------ transport.go | 7 ++++++- transport_test.go | 8 ++++++-- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/fuzz_test.go b/fuzz_test.go index f1f9998..eaace29 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -2,6 +2,7 @@ package bt import ( "net/http" + neturl "net/url" "strings" "testing" ) @@ -35,19 +36,30 @@ func FuzzBuildThreads(f *testing.F) { }) } -// FuzzRedactURL: whatever the input, no query token value or userinfo -// password may survive into the output. +// FuzzRedactURL: when the input PARSES as a URL whose token query value or +// userinfo password contains the canary, the canary must not survive into +// the output. (Raw substring matching would misfire on inputs like a bare +// "token=X" that have no URL credential semantics.) func FuzzRedactURL(f *testing.F) { f.Add("https://uni.sp.backtrace.io/post?format=json&token=SECRETCANARY") f.Add("https://submit.backtrace.io/universe/SECRETCANARY/json") f.Add("https://user:SECRETCANARY@host/path") f.Add("http://%zz") f.Fuzz(func(t *testing.T, raw string) { - out := redactURL(raw) - if strings.Contains(raw, "SECRETCANARY") && - strings.Contains(out, "SECRETCANARY") && - (strings.Contains(raw, "token=SECRETCANARY") || - strings.Contains(raw, ":SECRETCANARY@")) { + out := redactURL(raw) // must never panic + u, err := neturl.Parse(raw) + if err != nil { + // Unparsable input degrades to a fixed placeholder. + if out != "[REDACTED URL]" { + t.Fatalf("unparsable URL leaked: %q -> %q", raw, out) + } + return + } + credential := strings.Contains(u.Query().Get("token"), "SECRETCANARY") + if pw, set := u.User.Password(); set && strings.Contains(pw, "SECRETCANARY") { + credential = true + } + if credential && strings.Contains(out, "SECRETCANARY") { t.Fatalf("credential survived redaction: %q -> %q", raw, out) } }) diff --git a/tracer_test.go b/tracer_test.go index e8a95cb..7424b2b 100644 --- a/tracer_test.go +++ b/tracer_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "sync" - "syscall" "testing" "time" ) @@ -126,16 +125,8 @@ func TestPutSnapshotFileHardening(t *testing.T) { }) t.Run("non-regular file rejected", func(t *testing.T) { - fifo := filepath.Join(dir, "snap.fifo") - if err := syscall.Mkfifo(fifo, 0o644); err != nil { - t.Skipf("mkfifo unavailable: %v", err) - } - // Keep the FIFO openable without blocking: open the write end. - w, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0) - if err == nil { - defer w.Close() - } - + // A device file opens instantly (unlike a FIFO, whose blocking + // read-end open would hang the test) and is not regular. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("non-regular file reached the server") })) @@ -144,7 +135,7 @@ func TestPutSnapshotFileHardening(t *testing.T) { if err := tr.ConfigurePut(srv.URL, "tok", PutOptions{}); err != nil { t.Fatal(err) } - if err := tr.putSnapshotFile(fifo); err == nil || + if err := tr.putSnapshotFile(os.DevNull); err == nil || !strings.Contains(err.Error(), "regular file") { t.Errorf("err = %v, want regular-file rejection", err) } diff --git a/transport.go b/transport.go index b445752..562c6b1 100644 --- a/transport.go +++ b/transport.go @@ -173,6 +173,11 @@ const maxMultipartNameLength = 255 // basename for use in multipart part names and filenames. func safeMultipartName(path string) string { name := filepath.Base(path) + // Degenerate basenames first: on Windows, Base("/") is `\`, which the + // character mapping below would otherwise turn into "_". + if name == "" || name == "." || name == ".." || name == "/" || name == `\` { + return "attachment" + } name = strings.Map(func(r rune) rune { switch { case r == '\r' || r == '\n' || r == 0 || r == '"' || r == '\\': @@ -183,7 +188,7 @@ func safeMultipartName(path string) string { return r } }, name) - if name == "" || name == "." || name == ".." || name == "/" || name == `\` { + if name == "" { return "attachment" } if len(name) > maxMultipartNameLength { diff --git a/transport_test.go b/transport_test.go index ec3a008..d53f74f 100644 --- a/transport_test.go +++ b/transport_test.go @@ -120,12 +120,16 @@ func TestSanitizeHTTPError(t *testing.T) { } func TestSafeMultipartName(t *testing.T) { + // Only separator-free basenames and portable paths: filepath.Base + // treats `\` as a separator on Windows, so embedded-backslash + // expectations would be platform-dependent. cases := []struct{ in, want string }{ {"/var/log/app.log", "app.log"}, - {"/tmp/evil\r\nname", "evil__name"}, - {"/tmp/quote\"back\\slash", "quote_back_slash"}, + {"evil\r\nname", "evil__name"}, + {"quote\"file", "quote_file"}, {"/", "attachment"}, {".", "attachment"}, + {"..", "attachment"}, } for _, c := range cases { if got := safeMultipartName(c.in); got != c.want { From d43f3d331cf57c043d29fd77042c366b63c318e8 Mon Sep 17 00:00:00 2001 From: melekr Date: Mon, 17 Aug 2026 13:02:44 -0400 Subject: [PATCH 13/13] ci: fetch the newest Go patch release and scope the govulncheck gate --- .github/workflows/tests.yml | 47 ++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c2d49bf..b9d9b67 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.26.x' + check-latest: true - name: Check gofmt run: | unformatted="$(gofmt -l .)" @@ -52,6 +53,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} + check-latest: true - name: Vet run: go vet ./... - name: Run unit tests (race detector) @@ -67,10 +69,51 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.26.x' + check-latest: true - name: Scan for known vulnerabilities run: | go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 - govulncheck ./... + + # Full human-readable report always appears in the log. + govulncheck ./... || true + + # Gate policy: vulnerabilities reachable in this module's own + # dependencies FAIL the build — those ship to users through + # go.mod. Standard-library findings are advisory here: consumers + # compile this library with their own Go toolchain, so the CI + # toolchain's stdlib is not shipped, and a strict gate would turn + # red on an unrelated schedule every time a Go patch release lands + # before the runner image's cached toolchain catches up. + # `check-latest: true` above keeps the toolchain current, so + # stdlib findings should normally be empty anyway. + govulncheck -format json ./... > govulncheck.json || true + + dependency_vulns="$(jq -s -r ' + [ .[] + | select(.finding) + | .finding + | select(.trace[0].function) # reachable symbol + | select(.trace[0].module != "stdlib") # not the toolchain + | .osv ] | unique | .[]' govulncheck.json)" + + stdlib_vulns="$(jq -s -r ' + [ .[] + | select(.finding) + | .finding + | select(.trace[0].function) + | select(.trace[0].module == "stdlib") + | .osv ] | unique | .[]' govulncheck.json)" + + if [ -n "$stdlib_vulns" ]; then + echo "::warning title=Go stdlib vulnerabilities::Advisory only (fixed by a newer Go toolchain): $(echo "$stdlib_vulns" | tr '\n' ' ')" + fi + + if [ -n "$dependency_vulns" ]; then + echo "::error title=Dependency vulnerabilities::$(echo "$dependency_vulns" | tr '\n' ' ')" + exit 1 + fi + + echo "No reachable dependency vulnerabilities." fuzz-smoke: name: fuzz (smoke) @@ -82,6 +125,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.26.x' + check-latest: true - name: Fuzz parsers and redaction briefly run: | for target in FuzzBuildThreads FuzzRedactURL FuzzRetryAfter FuzzSplitQualifiedFunction FuzzSafeMultipartName; do @@ -108,6 +152,7 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.26.x' + check-latest: true - name: Build run: | export GOOS="${TARGET%/*}" GOARCH="${TARGET#*/}"