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} }