Some checks failed
Report errors, panics and optional performance traces to Sentry. Sentry stays disabled unless SENTRY_DSN is set. - slog ERROR records become Sentry issues, with the error attribute promoted to an exception so issues group by root cause - queue worker, reminder worker, cache cleanup and HTTP handler panics are captured with a stack trace - events carry platform/plugin/component tags for filtering - HTTP requests are traced when SENTRY_TRACES_SAMPLE_RATE is above 0; /healthz is never traced Configured credentials are redacted from every outgoing payload. This is required rather than defensive: the Telegram webhook embeds the bot token in its URL path, and failed Telegram API calls quote that URL in their error text, so events would otherwise carry the token in the clear. A new platform must register its credential in Config.Secrets(). Also moves the module to Go 1.27, refreshes every dependency and pins golangci-lint v2.13.2. sentry-go 0.48 removed issue creation from its slog integration, so the ERROR-to-issue conversion lives in internal/observability/handler.go instead of relying on the SDK; leaving it to the SDK would have silently downgraded issues to log lines. Claude-Session: https://claude.ai/code/session_01W7tcpMTEyk9RrHvT7Be5zZ
176 lines
4.5 KiB
Go
176 lines
4.5 KiB
Go
package observability
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
sentryslog "github.com/getsentry/sentry-go/slog"
|
|
|
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
|
)
|
|
|
|
// maxErrorDepth bounds how far a wrapped error chain is unwrapped into Sentry
|
|
// exceptions.
|
|
const maxErrorDepth = 10
|
|
|
|
// logContextKey names the Sentry context block holding a record's attributes.
|
|
const logContextKey = "log"
|
|
|
|
// loggerName identifies the source of events raised from log records.
|
|
const loggerName = "slog"
|
|
|
|
// errorKeys are the attribute names conventionally carrying an error value.
|
|
var errorKeys = map[string]bool{"error": true, "err": true}
|
|
|
|
// WrapLogger returns a logger that writes to the original handler and, when
|
|
// Sentry is enabled, also reports to Sentry.
|
|
func WrapLogger(logger *slog.Logger, cfg config.SentryConfig) *slog.Logger {
|
|
if !Enabled() {
|
|
return logger
|
|
}
|
|
|
|
handlers := []slog.Handler{logger.Handler(), &errorEventHandler{}}
|
|
|
|
// The Sentry SDK's slog integration only emits log entries, so it is added
|
|
// solely for the opt-in log stream; issues come from errorEventHandler.
|
|
if cfg.EnableLogs {
|
|
handlers = append(handlers, sentryslog.Option{AddSource: true}.NewSentryHandler(context.Background()))
|
|
}
|
|
|
|
return slog.New(&fanoutHandler{handlers: handlers})
|
|
}
|
|
|
|
// errorEventHandler turns ERROR records into Sentry issues, using the hub
|
|
// carried by the record's context so scoped tags apply.
|
|
type errorEventHandler struct {
|
|
attrs []slog.Attr
|
|
prefix string
|
|
}
|
|
|
|
func (h *errorEventHandler) Enabled(_ context.Context, level slog.Level) bool {
|
|
return level >= slog.LevelError
|
|
}
|
|
|
|
func (h *errorEventHandler) Handle(ctx context.Context, record slog.Record) error {
|
|
hub := sentry.GetHubFromContext(ctx)
|
|
if hub == nil {
|
|
hub = sentry.CurrentHub()
|
|
}
|
|
|
|
event := sentry.NewEvent()
|
|
event.Level = sentry.LevelError
|
|
event.Message = record.Message
|
|
event.Timestamp = record.Time.UTC()
|
|
event.Logger = loggerName
|
|
|
|
data := sentry.Context{}
|
|
for _, attr := range h.attrs {
|
|
collectAttr(data, event, "", attr)
|
|
}
|
|
record.Attrs(func(attr slog.Attr) bool {
|
|
collectAttr(data, event, h.prefix, attr)
|
|
return true
|
|
})
|
|
|
|
if len(data) > 0 {
|
|
event.Contexts[logContextKey] = data
|
|
}
|
|
|
|
hub.CaptureEvent(event)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (h *errorEventHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
next := &errorEventHandler{
|
|
attrs: make([]slog.Attr, 0, len(h.attrs)+len(attrs)),
|
|
prefix: h.prefix,
|
|
}
|
|
next.attrs = append(next.attrs, h.attrs...)
|
|
|
|
// Qualify now, so stored attributes stay independent of later groups.
|
|
for _, attr := range attrs {
|
|
next.attrs = append(next.attrs, slog.Attr{Key: h.prefix + attr.Key, Value: attr.Value})
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
func (h *errorEventHandler) WithGroup(name string) slog.Handler {
|
|
if name == "" {
|
|
return h
|
|
}
|
|
|
|
return &errorEventHandler{attrs: h.attrs, prefix: h.prefix + name + "."}
|
|
}
|
|
|
|
// collectAttr flattens an attribute into the event, promoting an error value to
|
|
// a Sentry exception so issues group by the underlying error.
|
|
func collectAttr(data sentry.Context, event *sentry.Event, prefix string, attr slog.Attr) {
|
|
key := prefix + attr.Key
|
|
value := attr.Value.Resolve()
|
|
|
|
if value.Kind() == slog.KindGroup {
|
|
for _, nested := range value.Group() {
|
|
collectAttr(data, event, key+".", nested)
|
|
}
|
|
return
|
|
}
|
|
|
|
if errorKeys[attr.Key] {
|
|
if err, ok := value.Any().(error); ok {
|
|
event.SetException(err, maxErrorDepth)
|
|
return
|
|
}
|
|
}
|
|
|
|
data[key] = value.Any()
|
|
}
|
|
|
|
// fanoutHandler dispatches every record to all wrapped handlers.
|
|
type fanoutHandler struct {
|
|
handlers []slog.Handler
|
|
}
|
|
|
|
func (h *fanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|
for _, handler := range h.handlers {
|
|
if handler.Enabled(ctx, level) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *fanoutHandler) Handle(ctx context.Context, record slog.Record) error {
|
|
var err error
|
|
for _, handler := range h.handlers {
|
|
if !handler.Enabled(ctx, record.Level) {
|
|
continue
|
|
}
|
|
if handleErr := handler.Handle(ctx, record.Clone()); handleErr != nil {
|
|
err = handleErr
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (h *fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
handlers := make([]slog.Handler, len(h.handlers))
|
|
for i, handler := range h.handlers {
|
|
handlers[i] = handler.WithAttrs(attrs)
|
|
}
|
|
return &fanoutHandler{handlers: handlers}
|
|
}
|
|
|
|
func (h *fanoutHandler) WithGroup(name string) slog.Handler {
|
|
if name == "" {
|
|
return h
|
|
}
|
|
|
|
handlers := make([]slog.Handler, len(h.handlers))
|
|
for i, handler := range h.handlers {
|
|
handlers[i] = handler.WithGroup(name)
|
|
}
|
|
return &fanoutHandler{handlers: handlers}
|
|
}
|