The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
113 lines
3.0 KiB
Go
113 lines
3.0 KiB
Go
// 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)
|
|
}
|
|
}
|