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
169 lines
4.6 KiB
Go
169 lines
4.6 KiB
Go
package observability
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
|
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
|
)
|
|
|
|
// testToken mimics the shape of a Telegram bot token, which the webhook URL
|
|
// embeds in its path.
|
|
const testToken = "123456:AAFakeTelegramBotTokenValue"
|
|
|
|
// captureTransport records events locally instead of shipping them to Sentry.
|
|
type captureTransport struct {
|
|
mu sync.Mutex
|
|
events []*sentry.Event
|
|
}
|
|
|
|
func (t *captureTransport) Configure(sentry.ClientOptions) {}
|
|
|
|
func (t *captureTransport) SendEvent(event *sentry.Event) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
t.events = append(t.events, event)
|
|
}
|
|
|
|
func (t *captureTransport) Flush(time.Duration) bool { return true }
|
|
|
|
func (t *captureTransport) FlushWithContext(context.Context) bool { return true }
|
|
|
|
func (t *captureTransport) Close() {}
|
|
|
|
func (t *captureTransport) captured() []*sentry.Event {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
return append([]*sentry.Event(nil), t.events...)
|
|
}
|
|
|
|
// enableSentry binds a client that captures events in memory to the current
|
|
// hub, and unbinds it when the test ends.
|
|
func enableSentry(t *testing.T, secrets []string) *captureTransport {
|
|
t.Helper()
|
|
|
|
transport := &captureTransport{}
|
|
|
|
options := clientOptions(config.SentryConfig{
|
|
DSN: "https://key@example.invalid/1",
|
|
Environment: "test",
|
|
TracesSampleRate: 1.0,
|
|
}, false, secrets)
|
|
options.Transport = transport
|
|
|
|
client, err := sentry.NewClient(options)
|
|
if err != nil {
|
|
t.Fatalf("failed to create client: %v", err)
|
|
}
|
|
|
|
hub := sentry.CurrentHub()
|
|
hub.BindClient(client)
|
|
t.Cleanup(func() { hub.BindClient(nil) })
|
|
|
|
return transport
|
|
}
|
|
|
|
func TestWebhookTokenIsScrubbedFromRequestContext(t *testing.T) {
|
|
transport := enableSentry(t, []string{testToken})
|
|
|
|
handler := HTTPMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
sentry.GetHubFromContext(r.Context()).CaptureMessage("handler failure")
|
|
}))
|
|
|
|
handler.ServeHTTP(httptest.NewRecorder(),
|
|
httptest.NewRequest(http.MethodPost, "/telegram/incoming/"+testToken, nil))
|
|
|
|
events := transport.captured()
|
|
if len(events) < 2 {
|
|
t.Fatalf("expected an error event and a transaction, got %d", len(events))
|
|
}
|
|
|
|
for _, event := range events {
|
|
if event.Request != nil && strings.Contains(event.Request.URL, testToken) {
|
|
t.Errorf("request URL leaks the bot token: %s", event.Request.URL)
|
|
}
|
|
if strings.Contains(event.Transaction, testToken) {
|
|
t.Errorf("transaction name leaks the bot token: %s", event.Transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestErrorLogIsForwardedTaggedAndScrubbed(t *testing.T) {
|
|
transport := enableSentry(t, []string{testToken})
|
|
|
|
logger := WrapLogger(slog.New(slog.NewTextHandler(io.Discard, nil)), config.SentryConfig{})
|
|
ctx := WithTags(context.Background(), map[string]string{"platform": "telegram"})
|
|
|
|
logger.ErrorContext(ctx, "Error sending message",
|
|
"error", `Post "https://api.telegram.org/bot`+testToken+`/sendMessage": timeout`)
|
|
|
|
events := transport.captured()
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected exactly one event, got %d", len(events))
|
|
}
|
|
|
|
event := events[0]
|
|
if event.Tags["platform"] != "telegram" {
|
|
t.Errorf("expected the platform tag to reach Sentry, got %v", event.Tags)
|
|
}
|
|
|
|
for _, exception := range event.Exception {
|
|
if strings.Contains(exception.Value, testToken) {
|
|
t.Errorf("exception value leaks the bot token: %s", exception.Value)
|
|
}
|
|
}
|
|
|
|
for name, context := range event.Contexts {
|
|
for key, value := range context {
|
|
if text, ok := value.(string); ok && strings.Contains(text, testToken) {
|
|
t.Errorf("context %s.%s leaks the bot token: %s", name, key, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewScrubberIgnoresShortSecrets(t *testing.T) {
|
|
if newScrubber([]string{"", "1234"}) != nil {
|
|
t.Fatal("expected short secrets to be ignored")
|
|
}
|
|
|
|
if newScrubber([]string{testToken}) == nil {
|
|
t.Fatal("expected a scrubber for a usable secret")
|
|
}
|
|
}
|
|
|
|
func TestScrubberRedactsNestedValues(t *testing.T) {
|
|
s := newScrubber([]string{testToken})
|
|
|
|
event := sentry.NewEvent()
|
|
event.Message = "failed calling " + testToken
|
|
event.Contexts[logContextKey] = sentry.Context{
|
|
"nested": map[string]any{"url": "https://api.telegram.org/bot" + testToken},
|
|
"count": 3,
|
|
}
|
|
|
|
scrubbed := s.event(event)
|
|
|
|
if strings.Contains(scrubbed.Message, testToken) {
|
|
t.Errorf("message leaks the token: %s", scrubbed.Message)
|
|
}
|
|
|
|
data := scrubbed.Contexts[logContextKey]
|
|
nested := data["nested"].(map[string]any)
|
|
if strings.Contains(nested["url"].(string), testToken) {
|
|
t.Errorf("nested value leaks the token: %v", nested["url"])
|
|
}
|
|
|
|
if data["count"] != 3 {
|
|
t.Errorf("expected non-string values to be untouched, got %v", data["count"])
|
|
}
|
|
}
|