Go · 3178 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package errrep wires the Sentry-protocol-compatible error reporter.
4 // When DSN is empty, every entry point becomes a no-op so callers don't
5 // need to branch. The wire format works against either Sentry SaaS or a
6 // self-hosted GlitchTip instance (the lean per S03 sprint spec).
7 package errrep
8
9 import (
10 "context"
11 "errors"
12 "fmt"
13 "log/slog"
14 "runtime/debug"
15 "time"
16
17 "github.com/getsentry/sentry-go"
18 )
19
20 // Config controls SDK initialization.
21 type Config struct {
22 DSN string
23 Environment string
24 Release string
25 }
26
27 // Init initializes the SDK. Returns a flush function the caller calls on
28 // shutdown to drain any queued events. When DSN is empty Init is a no-op
29 // and the returned flush is a no-op too.
30 func Init(cfg Config) (func(context.Context) error, error) {
31 if cfg.DSN == "" {
32 return func(context.Context) error { return nil }, nil
33 }
34 err := sentry.Init(sentry.ClientOptions{
35 Dsn: cfg.DSN,
36 Environment: cfg.Environment,
37 Release: cfg.Release,
38 AttachStacktrace: true,
39 EnableTracing: false,
40 })
41 if err != nil {
42 return nil, fmt.Errorf("errrep: sentry init: %w", err)
43 }
44 return func(_ context.Context) error {
45 if !sentry.Flush(3 * time.Second) {
46 return errors.New("errrep: flush did not complete")
47 }
48 return nil
49 }, nil
50 }
51
52 // CaptureException reports an error. Safe to call when the SDK is not
53 // configured (it's a no-op).
54 func CaptureException(err error) {
55 if err == nil {
56 return
57 }
58 sentry.CaptureException(err)
59 }
60
61 // CapturePanic reports a recovered panic value. requestID is used to
62 // correlate with logs and traces.
63 func CapturePanic(recovered any, requestID string) {
64 if recovered == nil {
65 return
66 }
67 hub := sentry.CurrentHub().Clone()
68 hub.WithScope(func(scope *sentry.Scope) {
69 if requestID != "" {
70 scope.SetTag("request_id", requestID)
71 }
72 scope.SetContext("shithub", sentry.Context{
73 "stack": string(debug.Stack()),
74 })
75 hub.RecoverWithContext(context.Background(), recovered)
76 })
77 }
78
79 // SlogHandler wraps an underlying slog.Handler so that records at error
80 // level are also reported to Sentry. Other levels pass through unchanged.
81 type SlogHandler struct {
82 Inner slog.Handler
83 }
84
85 func (h *SlogHandler) Enabled(ctx context.Context, level slog.Level) bool {
86 return h.Inner.Enabled(ctx, level)
87 }
88
89 func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
90 if r.Level >= slog.LevelError {
91 hub := sentry.CurrentHub().Clone()
92 hub.WithScope(func(scope *sentry.Scope) {
93 extras := sentry.Context{}
94 r.Attrs(func(a slog.Attr) bool {
95 switch a.Key {
96 case "request_id", "user_id", "component", "route":
97 scope.SetTag(a.Key, a.Value.String())
98 default:
99 extras[a.Key] = a.Value.Any()
100 }
101 return true
102 })
103 if len(extras) > 0 {
104 scope.SetContext("shithub", extras)
105 }
106 hub.CaptureMessage(r.Message)
107 })
108 }
109 return h.Inner.Handle(ctx, r)
110 }
111
112 func (h *SlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
113 return &SlogHandler{Inner: h.Inner.WithAttrs(attrs)}
114 }
115
116 func (h *SlogHandler) WithGroup(name string) slog.Handler {
117 return &SlogHandler{Inner: h.Inner.WithGroup(name)}
118 }
119