tenseleyflow/shithub / 0661c5a

Browse files

Add Sentry-protocol error reporter (no-op when DSN empty) with slog handler

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0661c5a3f71d42f503025677a0b9f725c78fdd11
Parents
0aba572
Tree
c93befc

1 changed file

StatusFile+-
A internal/infra/errrep/errrep.go 118 0
internal/infra/errrep/errrep.goadded
@@ -0,0 +1,118 @@
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
+}