From 3d6142a62e97ef6f625db8bcbd5d5f609b2b5988 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sun, 29 Mar 2026 01:00:26 +0100 Subject: [PATCH] feat: add Sentry error reporting Capture command errors and panics with Sentry SDK. DSN injected at build time via ldflags (dev builds silent, releases report). Opt-out: UNARR_NO_TELEMETRY=1. --- .goreleaser.yml | 1 + Makefile | 4 +- cmd/unarr/main.go | 9 ++++- go.mod | 1 + go.sum | 8 ++++ internal/cmd/root.go | 14 +++++++ internal/sentry/sentry.go | 85 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 internal/sentry/sentry.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 0beec41..3ae8d78 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,6 +17,7 @@ builds: ldflags: - -s -w - -X github.com/torrentclaw/torrentclaw-cli/internal/cmd.Version={{.Version}} + - -X github.com/torrentclaw/torrentclaw-cli/internal/sentry.dsn={{ .Env.SENTRY_DSN }} upx: - enabled: true diff --git a/Makefile b/Makefile index fb6ed14..1e153ea 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,14 @@ .PHONY: all build test lint coverage clean fmt vet check install-hooks BINARY = unarr +SENTRY_DSN ?= +LDFLAGS = -X github.com/torrentclaw/torrentclaw-cli/internal/sentry.dsn=$(SENTRY_DSN) all: fmt vet lint test build ## Build the binary build: - go build -o $(BINARY) ./cmd/unarr/ + go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/unarr/ ## Run all tests test: diff --git a/cmd/unarr/main.go b/cmd/unarr/main.go index 24f1503..ae89c25 100644 --- a/cmd/unarr/main.go +++ b/cmd/unarr/main.go @@ -1,7 +1,14 @@ package main -import "github.com/torrentclaw/torrentclaw-cli/internal/cmd" +import ( + "github.com/torrentclaw/torrentclaw-cli/internal/cmd" + "github.com/torrentclaw/torrentclaw-cli/internal/sentry" +) func main() { + sentry.Init(cmd.Version) + defer sentry.Close() + defer sentry.RecoverPanic() + cmd.Execute() } diff --git a/go.mod b/go.mod index 87c8876..0832f76 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/anacrolix/torrent v1.61.0 github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 + github.com/getsentry/sentry-go v0.44.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index d329cf5..4401697 100644 --- a/go.sum +++ b/go.sum @@ -176,12 +176,16 @@ github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= +github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-llsqlite/adapter v0.2.0 h1:6k4dmTSTg1eKIeH+2kBWaoohn9SFNZeg4LWayZweevI= @@ -331,6 +335,8 @@ github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= @@ -458,6 +464,8 @@ go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUl go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 0779299..51c77c1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,6 +7,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" "github.com/torrentclaw/torrentclaw-cli/internal/config" + "github.com/torrentclaw/torrentclaw-cli/internal/sentry" tc "github.com/torrentclaw/go-client" ) @@ -144,6 +145,14 @@ Source: https://github.com/torrentclaw/torrentclaw-cli`, // Execute runs the root command. func Execute() { if err := rootCmd.Execute(); err != nil { + // Report to Sentry with command context + command := "" + if cmd, _, cerr := rootCmd.Find(os.Args[1:]); cerr == nil && cmd != nil && cmd != rootCmd { + command = cmd.Name() + } + sentry.CaptureError(err, command) + sentry.Close() // Flush before os.Exit (defers don't run after os.Exit) + fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err)) os.Exit(1) } @@ -164,6 +173,11 @@ func loadConfig() config.Config { appCfg.ApplyEnvOverrides() cfgLoaded = true + + if appCfg.Agent.ID != "" { + sentry.SetUser(appCfg.Agent.ID) + } + return appCfg } diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go new file mode 100644 index 0000000..ee6423a --- /dev/null +++ b/internal/sentry/sentry.go @@ -0,0 +1,85 @@ +package sentry + +import ( + "os" + "runtime" + "strings" + "time" + + gosentry "github.com/getsentry/sentry-go" +) + +// dsn is injected at build time via ldflags. If empty, Sentry is disabled. +// Set via: -ldflags "-X github.com/torrentclaw/torrentclaw-cli/internal/sentry.dsn=..." +var dsn string + +const flushTimeout = 2 * time.Second + +// Init initializes the Sentry SDK. Call Close() on shutdown to flush events. +// No-op if telemetry is disabled (UNARR_NO_TELEMETRY=1). +func Init(version string) { + if dsn == "" || os.Getenv("UNARR_NO_TELEMETRY") == "1" { + return + } + + err := gosentry.Init(gosentry.ClientOptions{ + Dsn: dsn, + Release: "unarr@" + version, + Environment: environment(version), + AttachStacktrace: true, + }) + if err != nil { + return + } + + gosentry.ConfigureScope(func(scope *gosentry.Scope) { + scope.SetTag("os", runtime.GOOS) + scope.SetTag("arch", runtime.GOARCH) + scope.SetTag("go_version", runtime.Version()) + }) +} + +// Close flushes pending events with a timeout. +func Close() { + gosentry.Flush(flushTimeout) +} + +// CaptureError sends a non-fatal error to Sentry with optional command context. +func CaptureError(err error, command string) { + if err == nil { + return + } + + gosentry.WithScope(func(scope *gosentry.Scope) { + if command != "" { + scope.SetTag("command", command) + } + gosentry.CaptureException(err) + }) +} + +// RecoverPanic captures a panic and re-panics after reporting. +// Usage: defer sentry.RecoverPanic() +func RecoverPanic() { + if r := recover(); r != nil { + gosentry.CurrentHub().Recover(r) + gosentry.Flush(flushTimeout) + + // Re-panic so the user sees the stack trace and the process exits non-zero + panic(r) + } +} + +// SetUser sets the user context (agent ID) for all subsequent events. +func SetUser(agentID string) { + gosentry.ConfigureScope(func(scope *gosentry.Scope) { + scope.SetUser(gosentry.User{ID: agentID}) + }) +} + +func environment(version string) string { + if version == "" || version == "dev" || strings.HasSuffix(version, "-dev") { + return "development" + } + return "production" +}