// Package telemetry owns error reporting (Sentry) and metrics // (Prometheus). Both are optional at runtime and neither is allowed to // take the process down: a monitoring backend that is unreachable must // not stop the service it monitors. package telemetry import ( "context" "fmt" "log/slog" "time" "github.com/getsentry/sentry-go" "go.uber.org/fx" "sneak.berlin/go/simplexcalc/internal/config" "sneak.berlin/go/simplexcalc/internal/globals" "sneak.berlin/go/simplexcalc/internal/logger" ) // flushTimeout bounds how long shutdown waits for queued events to // reach Sentry. Exceeding it drops the tail rather than hanging the // stop sequence. const flushTimeout = 2 * time.Second // SentryParams defines dependencies for Sentry. type SentryParams struct { fx.In Config *config.Config Globals *globals.Globals Logger *logger.Logger } // Sentry wraps the client. When SENTRY_DSN is unset the wrapper still // exists and every method is a no-op, so no caller needs a nil check // and no caller behaves differently in development. type Sentry struct { enabled bool log *slog.Logger } // NewSentry initialises the client if a DSN is configured. A DSN that // is present but malformed has already failed config parsing; a DSN the // client itself rejects is logged and reporting stays off, because a // telemetry backend is not a reason to refuse to serve. func NewSentry(lc fx.Lifecycle, params SentryParams) (*Sentry, error) { s := &Sentry{log: params.Logger.Get()} if params.Config.SentryDSN == "" { s.log.Info("sentry disabled", "reason", "no DSN configured") return s, nil } err := sentry.Init(sentry.ClientOptions{ Dsn: params.Config.SentryDSN, Environment: params.Config.SentryEnvironment, Release: params.Globals.Appname + "@" + params.Globals.Version, // Panics are reported explicitly by the recovery middleware, // which also has to answer the request; letting the SDK // re-raise them would take the process down. Debug: params.Config.Debug, }) if err != nil { s.log.Error("sentry init failed; error reporting is off", "error", err) return s, nil } s.enabled = true s.log.Info("sentry enabled", "environment", params.Config.SentryEnvironment) lc.Append(fx.Hook{ OnStop: func(_ context.Context) error { sentry.Flush(flushTimeout) return nil }, }) return s, nil } // Enabled reports whether events are actually being sent. func (s *Sentry) Enabled() bool { return s.enabled } // CaptureError reports an error, and always logs it. Logging is not // conditional on Sentry being on: the log is the record of record, and // Sentry is a convenience on top of it. func (s *Sentry) CaptureError(err error) { if err == nil { return } s.log.Error("captured error", "error", err) if s.enabled { sentry.CaptureException(err) } } // CapturePanic reports a recovered panic value with its stack. func (s *Sentry) CapturePanic(v any, stack []byte) { s.log.Error("recovered panic", "panic", fmt.Sprint(v), "stack", string(stack)) if s.enabled { sentry.CurrentHub().Recover(v) } }