From d698fd802b7144ef8e5d7e6347a086dc53087f39 Mon Sep 17 00:00:00 2001 From: Ben Hall Date: Tue, 6 Oct 2026 20:48:19 +0100 Subject: [PATCH] feat: tidy up sentry errors for common discord errors Signed-off-by: Ben Hall --- bot/command/context/replyable.go | 39 ++++++++++++++- bot/command/context/replyable_test.go | 68 +++++++++++++++++++++++++++ bot/logging/logging.go | 5 ++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 bot/command/context/replyable_test.go diff --git a/bot/command/context/replyable.go b/bot/command/context/replyable.go index 589a1588..95b221e5 100644 --- a/bot/command/context/replyable.go +++ b/bot/command/context/replyable.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "runtime" "strings" "time" @@ -28,6 +29,7 @@ import ( "github.com/TicketsBot-cloud/worker/bot/utils" "github.com/TicketsBot-cloud/worker/config" "github.com/TicketsBot-cloud/worker/i18n" + "go.uber.org/zap" ) type Replyable struct { @@ -134,7 +136,21 @@ func (r *Replyable) HandleError(err error) { fmt.Printf("ctx.HandleError: %s\n", err.Error()) } - eventId := logging.ErrorWithContext(err, r.ctx.ToErrorContext()) + var eventId string + if isExpectedDiscordError(err) { + // No Sentry stack trace on this path, so record which handler raised it. + var restError request.RestError + errors.As(err, &restError) + _, file, line, _ := runtime.Caller(1) + + eventId = logging.LocalOnly(err, r.ctx.ToErrorContext(), + zap.Int("discord_code", restError.ApiError.Code), + zap.Int("status_code", restError.StatusCode), + zap.String("call_site", fmt.Sprintf("%s:%d", file, line)), + ) + } else { + eventId = logging.ErrorWithContext(err, r.ctx.ToErrorContext()) + } if errors.Is(err, ErrReplyLimitReached) { return @@ -151,6 +167,27 @@ func (r *Replyable) HandleError(err error) { _, _ = r.ctx.ReplyWith(res) } +// isExpectedDiscordError reports guild state/config errors that buildErrorResponse +// already answers with a friendly message and no event ID, so Sentry gains nothing. +// Kept out on purpose: 10062 and 40060 (slow or double ack, our fault), 429s, and +// 50035, whose fallback branch shows the event ID. +func isExpectedDiscordError(err error) bool { + var restError request.RestError + if !errors.As(err, &restError) { + return false + } + + switch restError.ApiError.Code { + case 10003, 10004, 10007, 10008, 10011, 10013, 10059, + 30007, 30013, + 50001, 50013, + 160005, 160006, 160007: + return true + default: + return false + } +} + func (r *Replyable) HandleWarning(err error) { eventId := logging.WarnWithContext(err, r.ctx.ToErrorContext()) diff --git a/bot/command/context/replyable_test.go b/bot/command/context/replyable_test.go new file mode 100644 index 00000000..fefcdac2 --- /dev/null +++ b/bot/command/context/replyable_test.go @@ -0,0 +1,68 @@ +package context + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/TicketsBot-cloud/gdl/rest/request" + "github.com/stretchr/testify/require" +) + +func TestIsExpectedDiscordError(t *testing.T) { + restErr := func(status, code int) error { + return request.RestError{ + StatusCode: status, + ApiError: request.ApiV8Error{Code: code}, + } + } + + type testCase struct { + name string + err error + expected bool + } + + tests := []testCase{ + {name: "nil error", err: nil, expected: false}, + {name: "plain error", err: errors.New("boom"), expected: false}, + {name: "deadline exceeded", err: context.DeadlineExceeded, expected: false}, + {name: "unknown interaction 10062", err: restErr(http.StatusNotFound, 10062), expected: false}, + {name: "already acknowledged 40060", err: restErr(http.StatusBadRequest, 40060), expected: false}, + {name: "invalid form body 50035", err: restErr(http.StatusBadRequest, 50035), expected: false}, + {name: "rate limited with no code", err: restErr(http.StatusTooManyRequests, 0), expected: false}, + {name: "unclassified code 50083", err: restErr(http.StatusBadRequest, 50083), expected: false}, + { + name: "wrapped expected error", + err: fmt.Errorf("creating channel: %w", restErr(http.StatusForbidden, 50001)), + expected: true, + }, + { + name: "wrapped excluded error", + err: fmt.Errorf("replying: %w", restErr(http.StatusNotFound, 10062)), + expected: false, + }, + } + + expectedCodes := []int{ + 10003, 10004, 10007, 10008, 10011, 10013, 10059, + 30007, 30013, + 50001, 50013, + 160005, 160006, 160007, + } + for _, code := range expectedCodes { + tests = append(tests, testCase{ + name: fmt.Sprintf("expected code %d", code), + err: restErr(http.StatusBadRequest, code), + expected: true, + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expected, isExpectedDiscordError(tt.err)) + }) + } +} diff --git a/bot/logging/logging.go b/bot/logging/logging.go index d9123a52..39eb2ccd 100644 --- a/bot/logging/logging.go +++ b/bot/logging/logging.go @@ -18,6 +18,11 @@ func WarnWithContext(err error, ctx sentry.ErrorContext, fields ...zap.Field) st return logLocally(err, ctx, sentry.LogWithContext(err, ctx), fields) } +// LocalOnly skips Sentry entirely; the returned ID is a local- one. +func LocalOnly(err error, ctx sentry.ErrorContext, fields ...zap.Field) string { + return logLocally(err, ctx, "", fields) +} + // logLocally always logs at zap Warn, deliberately never Error: common/observability's // ZapSentryAdapter hook (wired in cmd/worker/main.go) forwards every Error-level zap // line into its own independent, separately-sampled sentry.CaptureEvent call whose