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.
This commit is contained in:
Deivid Soto 2026-03-29 01:00:26 +01:00
parent 6e07e82d51
commit 3d6142a62e
7 changed files with 120 additions and 2 deletions

View file

@ -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

View file

@ -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:

View file

@ -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()
}

1
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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
}

85
internal/sentry/sentry.go Normal file
View file

@ -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"
}