refactor(sentry): decouple agent import via string-match, rename predicate
Some checks failed
CI / Test (push) Successful in 2m46s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m35s
CI / Build-3 (push) Successful in 1m35s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m39s
CI / Lint (push) Failing after 2m33s
CI / Coverage (push) Successful in 2m56s
CI / Vet (push) Successful in 2m7s

This commit is contained in:
Deivid Soto 2026-05-27 17:03:26 +02:00
parent 9fe796f195
commit 9135332777
5 changed files with 36 additions and 23 deletions

View file

@ -11,9 +11,11 @@ import (
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
) )
// ErrDaemonNotRunning is returned by callers that need a running daemon but // ErrDaemonNotRunning is returned when no daemon state file exists on disk.
// find no state file on disk. Sentinel so user-facing commands (stop/reload) // Callers may wrap it with %w; downstream code uses errors.Is to detect it.
// can wrap it and Sentry can filter it out as a non-bug. // NOTE: the message text is matched by the sentry package (string-match, to
// avoid an import cycle). Keep the prefix "daemon does not appear to be
// running" stable, or update sentry.daemonNotRunningMarker accordingly.
var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)") var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
// DaemonState is written to disk every heartbeat for external tools to read. // DaemonState is written to disk every heartbeat for external tools to read.

View file

@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -264,7 +265,10 @@ func runDaemonReload() error {
func stopDaemonByPID() error { func stopDaemonByPID() error {
state, err := agent.LoadState() state, err := agent.LoadState()
if err != nil { if err != nil {
return err if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
} }
return killPID(state.PID) return killPID(state.PID)
} }

View file

@ -3,6 +3,7 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -45,7 +46,10 @@ func startReloadWatcher(rc *ReloadableConfig) {
func sendReloadSignal() error { func sendReloadSignal() error {
state, err := agent.LoadState() state, err := agent.LoadState()
if err != nil { if err != nil {
return err if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
} }
p, err := os.FindProcess(state.PID) p, err := os.FindProcess(state.PID)
if err != nil { if err != nil {

View file

@ -9,8 +9,6 @@ import (
gosentry "github.com/getsentry/sentry-go" gosentry "github.com/getsentry/sentry-go"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/torrentclaw/unarr/internal/agent"
) )
// dsn is injected at build time via ldflags. If empty, Sentry is disabled. // dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@ -48,11 +46,16 @@ func Close() {
gosentry.Flush(flushTimeout) gosentry.Flush(flushTimeout)
} }
// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning
// without importing the agent package — avoids a sentry → agent dependency
// that would risk a cycle if agent ever needed to report errors itself.
const daemonNotRunningMarker = "daemon does not appear to be running"
// CaptureError sends a non-fatal error to Sentry with optional command context. // CaptureError sends a non-fatal error to Sentry with optional command context.
// User-input errors (unknown flag/command, bad value) are skipped — they are // Expected non-bug errors (bad CLI input, daemon not running) are skipped to
// not bugs, just noise. // keep the issue feed signal-heavy.
func CaptureError(err error, command string) { func CaptureError(err error, command string) {
if err == nil || isUserInputError(err) { if err == nil || shouldSkipSentry(err) {
return return
} }
@ -64,10 +67,7 @@ func CaptureError(err error, command string) {
}) })
} }
func isUserInputError(err error) bool { func shouldSkipSentry(err error) bool {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return true
}
var notExist *pflag.NotExistError var notExist *pflag.NotExistError
var valueReq *pflag.ValueRequiredError var valueReq *pflag.ValueRequiredError
var invalidVal *pflag.InvalidValueError var invalidVal *pflag.InvalidValueError
@ -78,7 +78,8 @@ func isUserInputError(err error) bool {
} }
msg := err.Error() msg := err.Error()
return strings.HasPrefix(msg, "unknown command ") || return strings.HasPrefix(msg, "unknown command ") ||
strings.HasPrefix(msg, "required flag(s)") strings.HasPrefix(msg, "required flag(s)") ||
strings.Contains(msg, daemonNotRunningMarker)
} }
// RecoverPanic captures a panic and re-panics after reporting. // RecoverPanic captures a panic and re-panics after reporting.

View file

@ -1,10 +1,9 @@
package sentry package sentry
import ( import (
"errors"
"fmt" "fmt"
"testing" "testing"
"github.com/torrentclaw/unarr/internal/agent"
) )
func TestEnvironment(t *testing.T) { func TestEnvironment(t *testing.T) {
@ -51,12 +50,15 @@ func TestSetUser(t *testing.T) {
SetUser("agent-123") SetUser("agent-123")
} }
func TestIsUserInputErrorDaemonNotRunning(t *testing.T) { func TestShouldSkipSentryDaemonNotRunning(t *testing.T) {
if !isUserInputError(agent.ErrDaemonNotRunning) { // String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel
t.Error("ErrDaemonNotRunning should be treated as user-input error") // is reworded, this test fails loudly so the marker can be updated.
err := errors.New("daemon does not appear to be running (state file not found)")
if !shouldSkipSentry(err) {
t.Error("ErrDaemonNotRunning message should be skipped")
} }
wrapped := fmt.Errorf("stop daemon: %w", agent.ErrDaemonNotRunning) wrapped := fmt.Errorf("read daemon state: %w", err)
if !isUserInputError(wrapped) { if !shouldSkipSentry(wrapped) {
t.Error("wrapped ErrDaemonNotRunning should be treated as user-input error") t.Error("wrapped ErrDaemonNotRunning message should be skipped")
} }
} }