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"
)
// 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.

View file

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

View file

@ -3,6 +3,7 @@
package cmd
import (
"errors"
"fmt"
"log"
"os"
@ -45,8 +46,11 @@ func startReloadWatcher(rc *ReloadableConfig) {
func sendReloadSignal() error {
state, err := agent.LoadState()
if err != nil {
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 {
return fmt.Errorf("find process %d: %w", state.PID, err)

View file

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

View file

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