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
137 lines
3.4 KiB
Go
137 lines
3.4 KiB
Go
package observability
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/getsentry/sentry-go/attribute"
|
|
)
|
|
|
|
// redacted replaces any secret value found in outgoing Sentry payloads.
|
|
const redacted = "[REDACTED]"
|
|
|
|
// minSecretLength guards against redacting short, common values that happen to
|
|
// be configured as a secret and would otherwise mangle every event.
|
|
const minSecretLength = 8
|
|
|
|
// scrubber removes configured secrets from payloads before they leave the
|
|
// process. Credentials reach Sentry through ordinary paths -- the Telegram
|
|
// webhook embeds the bot token in its URL, and failed API calls quote the
|
|
// request URL in their error text -- so scrubbing happens at the last moment,
|
|
// on the fully built event, rather than at each call site.
|
|
type scrubber struct {
|
|
secrets []string
|
|
}
|
|
|
|
// newScrubber returns nil when there is nothing worth scrubbing, which lets
|
|
// callers skip the BeforeSend hooks entirely.
|
|
func newScrubber(secrets []string) *scrubber {
|
|
var usable []string
|
|
for _, secret := range secrets {
|
|
if len(secret) >= minSecretLength {
|
|
usable = append(usable, secret)
|
|
}
|
|
}
|
|
|
|
if len(usable) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &scrubber{secrets: usable}
|
|
}
|
|
|
|
// text redacts every configured secret found in s.
|
|
func (s *scrubber) text(value string) string {
|
|
for _, secret := range s.secrets {
|
|
value = strings.ReplaceAll(value, secret, redacted)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// value redacts secrets inside arbitrary structured data.
|
|
func (s *scrubber) value(value any) any {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return s.text(typed)
|
|
case error:
|
|
return s.text(typed.Error())
|
|
case []any:
|
|
for i, item := range typed {
|
|
typed[i] = s.value(item)
|
|
}
|
|
return typed
|
|
case map[string]any:
|
|
for key, item := range typed {
|
|
typed[key] = s.value(item)
|
|
}
|
|
return typed
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
// event redacts secrets from every field that can carry free-form text.
|
|
func (s *scrubber) event(event *sentry.Event) *sentry.Event {
|
|
if event == nil {
|
|
return nil
|
|
}
|
|
|
|
event.Message = s.text(event.Message)
|
|
event.Transaction = s.text(event.Transaction)
|
|
|
|
for i := range event.Exception {
|
|
event.Exception[i].Type = s.text(event.Exception[i].Type)
|
|
event.Exception[i].Value = s.text(event.Exception[i].Value)
|
|
}
|
|
|
|
for i := range event.Breadcrumbs {
|
|
event.Breadcrumbs[i].Message = s.text(event.Breadcrumbs[i].Message)
|
|
for key, item := range event.Breadcrumbs[i].Data {
|
|
event.Breadcrumbs[i].Data[key] = s.value(item)
|
|
}
|
|
}
|
|
|
|
for name, context := range event.Contexts {
|
|
for key, item := range context {
|
|
event.Contexts[name][key] = s.value(item)
|
|
}
|
|
}
|
|
|
|
for key, item := range event.Tags {
|
|
event.Tags[key] = s.text(item)
|
|
}
|
|
|
|
if event.Request != nil {
|
|
event.Request.URL = s.text(event.Request.URL)
|
|
event.Request.QueryString = s.text(event.Request.QueryString)
|
|
event.Request.Cookies = s.text(event.Request.Cookies)
|
|
event.Request.Data = s.text(event.Request.Data)
|
|
for key, item := range event.Request.Headers {
|
|
event.Request.Headers[key] = s.text(item)
|
|
}
|
|
for key, item := range event.Request.Env {
|
|
event.Request.Env[key] = s.text(item)
|
|
}
|
|
}
|
|
|
|
return event
|
|
}
|
|
|
|
// log redacts secrets from a structured log entry.
|
|
func (s *scrubber) log(entry *sentry.Log) *sentry.Log {
|
|
if entry == nil {
|
|
return nil
|
|
}
|
|
|
|
entry.Body = s.text(entry.Body)
|
|
|
|
for key, value := range entry.Attributes {
|
|
text, ok := value.AsInterface().(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
entry.Attributes[key] = attribute.StringValue(s.text(text))
|
|
}
|
|
|
|
return entry
|
|
}
|