From 91353327775d280822dea1065e401146008e5cb5 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 17:03:26 +0200 Subject: [PATCH] refactor(sentry): decouple agent import via string-match, rename predicate --- internal/agent/state.go | 8 +++++--- internal/cmd/daemon_control.go | 6 +++++- internal/cmd/reload_unix.go | 6 +++++- internal/sentry/sentry.go | 21 +++++++++++---------- internal/sentry/sentry_test.go | 18 ++++++++++-------- 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/internal/agent/state.go b/internal/agent/state.go index bf0b93b..cc08ae5 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -11,9 +11,11 @@ import ( "github.com/torrentclaw/unarr/internal/config" ) -// ErrDaemonNotRunning is returned by callers that need a running daemon but -// find no state file on disk. Sentinel so user-facing commands (stop/reload) -// can wrap it and Sentry can filter it out as a non-bug. +// ErrDaemonNotRunning is returned when no daemon state file exists on disk. +// Callers may wrap it with %w; downstream code uses errors.Is to detect it. +// 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)") // DaemonState is written to disk every heartbeat for external tools to read. diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go index 277fc01..4ac4d10 100644 --- a/internal/cmd/daemon_control.go +++ b/internal/cmd/daemon_control.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "os/exec" @@ -264,7 +265,10 @@ func runDaemonReload() error { func stopDaemonByPID() error { state, err := agent.LoadState() 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) } diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go index 71736ea..34d8e4d 100644 --- a/internal/cmd/reload_unix.go +++ b/internal/cmd/reload_unix.go @@ -3,6 +3,7 @@ package cmd import ( + "errors" "fmt" "log" "os" @@ -45,7 +46,10 @@ func startReloadWatcher(rc *ReloadableConfig) { func sendReloadSignal() error { state, err := agent.LoadState() 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) if err != nil { diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go index fadf09a..3f16c08 100644 --- a/internal/sentry/sentry.go +++ b/internal/sentry/sentry.go @@ -9,8 +9,6 @@ import ( gosentry "github.com/getsentry/sentry-go" "github.com/spf13/pflag" - - "github.com/torrentclaw/unarr/internal/agent" ) // dsn is injected at build time via ldflags. If empty, Sentry is disabled. @@ -48,11 +46,16 @@ func Close() { 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. -// User-input errors (unknown flag/command, bad value) are skipped — they are -// not bugs, just noise. +// Expected non-bug errors (bad CLI input, daemon not running) are skipped to +// keep the issue feed signal-heavy. func CaptureError(err error, command string) { - if err == nil || isUserInputError(err) { + if err == nil || shouldSkipSentry(err) { return } @@ -64,10 +67,7 @@ func CaptureError(err error, command string) { }) } -func isUserInputError(err error) bool { - if errors.Is(err, agent.ErrDaemonNotRunning) { - return true - } +func shouldSkipSentry(err error) bool { var notExist *pflag.NotExistError var valueReq *pflag.ValueRequiredError var invalidVal *pflag.InvalidValueError @@ -78,7 +78,8 @@ func isUserInputError(err error) bool { } msg := err.Error() 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. diff --git a/internal/sentry/sentry_test.go b/internal/sentry/sentry_test.go index 49360d7..4005d14 100644 --- a/internal/sentry/sentry_test.go +++ b/internal/sentry/sentry_test.go @@ -1,10 +1,9 @@ package sentry import ( + "errors" "fmt" "testing" - - "github.com/torrentclaw/unarr/internal/agent" ) func TestEnvironment(t *testing.T) { @@ -51,12 +50,15 @@ func TestSetUser(t *testing.T) { SetUser("agent-123") } -func TestIsUserInputErrorDaemonNotRunning(t *testing.T) { - if !isUserInputError(agent.ErrDaemonNotRunning) { - t.Error("ErrDaemonNotRunning should be treated as user-input error") +func TestShouldSkipSentryDaemonNotRunning(t *testing.T) { + // String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel + // 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) - if !isUserInputError(wrapped) { - t.Error("wrapped ErrDaemonNotRunning should be treated as user-input error") + wrapped := fmt.Errorf("read daemon state: %w", err) + if !shouldSkipSentry(wrapped) { + t.Error("wrapped ErrDaemonNotRunning message should be skipped") } }