From 716e0320da64a6d10ce12562d56a4c7690b4310f Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Tue, 20 Oct 2026 15:25:07 -0400 Subject: [PATCH 1/2] chore(sentry): Send logs to Sentry --- go.mod | 1 + go.sum | 2 + internal/logutil/logutil.go | 59 ++++++++++++- internal/logutil/logutil_test.go | 137 +++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 internal/logutil/logutil_test.go diff --git a/go.mod b/go.mod index e8ae28ac..cf0de43b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/andybalholm/brotli v1.1.0 github.com/fsouza/fake-gcs-server v1.44.0 github.com/getsentry/sentry-go v1.0.0-alpha.1 + github.com/getsentry/sentry-go/slog v1.0.0-alpha.1 github.com/goccy/go-json v0.10.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index c55e7db3..42ddc08c 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/fsouza/fake-gcs-server v1.44.0 h1:Lw/mrvs45AfCUPVpry6qFkZnZPqe9thpLQH github.com/fsouza/fake-gcs-server v1.44.0/go.mod h1:M02aKoTv9Tnlf+gmWnTok1PWVCUHDntVbHxpd0krTfo= github.com/getsentry/sentry-go v1.0.0-alpha.1 h1:ZxMblX8orHwWvzkRZtzDTd98IsY1vnnOrzMcFnM3LvE= github.com/getsentry/sentry-go v1.0.0-alpha.1/go.mod h1:2v8sse8z+We+lVdchGIL0TG/4q/nwsud0EWpvikO5LA= +github.com/getsentry/sentry-go/slog v1.0.0-alpha.1 h1:V+LPMaj9c96NKsfd8aFUQ6WFhw8DeBdRLwdVbkb28ko= +github.com/getsentry/sentry-go/slog v1.0.0-alpha.1/go.mod h1:Uj7WyhtHmKq8jqb6bggOmoNs379A4pCb5Sw72aPvIgQ= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= diff --git a/internal/logutil/logutil.go b/internal/logutil/logutil.go index 3e3d0d9c..e2c8e438 100644 --- a/internal/logutil/logutil.go +++ b/internal/logutil/logutil.go @@ -1,12 +1,19 @@ package logutil import ( + "context" + "errors" "log/slog" "os" + "slices" + + sentryslog "github.com/getsentry/sentry-go/slog" ) +// ConfigureLogger sets up logging to both stdout and Sentry. (Messages sent +// before Sentry is initialized will only be sent to stdout.) func ConfigureLogger() { - slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { if a.Key == slog.MessageKey { @@ -14,5 +21,53 @@ func ConfigureLogger() { } return a }, - }))) + }) + // `context.Background()` here will use the global Sentry client. + sentryHandler := sentryslog.Option{}.NewSentryHandler(context.Background()) + h := teeHandler{handlers: []slog.Handler{jsonHandler, sentryHandler}} + slog.SetDefault(slog.New(h)) + slog.SetLogLoggerLevel(slog.LevelInfo) +} + +// teeHandler is a [slog.Handler] that forwards calls to all of its child +// handlers. +type teeHandler struct { + handlers []slog.Handler +} + +var _ slog.Handler = teeHandler{} + +func (t teeHandler) Enabled(ctx context.Context, level slog.Level) bool { + return slices.ContainsFunc(t.handlers, func(h slog.Handler) bool { + return h.Enabled(ctx, level) + }) +} + +func (t teeHandler) Handle(ctx context.Context, record slog.Record) error { + errs := make([]error, 0, len(t.handlers)) + for _, h := range t.handlers { + if !h.Enabled(ctx, record.Level) { + continue + } + if err := h.Handle(ctx, record); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (t teeHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + handlers := make([]slog.Handler, len(t.handlers)) + for i, h := range t.handlers { + handlers[i] = h.WithAttrs(attrs) + } + return &teeHandler{handlers: handlers} +} + +func (t teeHandler) WithGroup(name string) slog.Handler { + handlers := make([]slog.Handler, len(t.handlers)) + for i, h := range t.handlers { + handlers[i] = h.WithGroup(name) + } + return &teeHandler{handlers: handlers} } diff --git a/internal/logutil/logutil_test.go b/internal/logutil/logutil_test.go new file mode 100644 index 00000000..7e55b4b4 --- /dev/null +++ b/internal/logutil/logutil_test.go @@ -0,0 +1,137 @@ +package logutil + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "testing" + "time" + + "github.com/getsentry/vroom/internal/testutil" + "github.com/google/go-cmp/cmp" +) + +type recordingHandler struct { + slog.Handler + level slog.Level + err error + records []slog.Record + ctx context.Context +} + +func (h *recordingHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *recordingHandler) Handle(ctx context.Context, record slog.Record) error { + h.ctx = ctx + h.records = append(h.records, record) + return h.err +} + +func TestTeeHandlerEnabled(t *testing.T) { + for _, tt := range []struct { + name string + levels []slog.Level + want bool + }{ + {name: "empty"}, + {name: "all disabled", levels: []slog.Level{slog.LevelWarn, slog.LevelError}}, + {name: "first enabled", levels: []slog.Level{slog.LevelInfo, slog.LevelError}, want: true}, + {name: "last enabled", levels: []slog.Level{slog.LevelError, slog.LevelInfo}, want: true}, + {name: "all enabled", levels: []slog.Level{slog.LevelDebug, slog.LevelInfo}, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + var h teeHandler + for _, level := range tt.levels { + h.handlers = append(h.handlers, &recordingHandler{level: level}) + } + if got := h.Enabled(t.Context(), slog.LevelInfo); got != tt.want { + t.Errorf("Enabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTeeHandlerHandle(t *testing.T) { + firstErr := errors.New("first handler failed") + secondErr := errors.New("second handler failed") + disabledErr := errors.New("disabled handler called") + for _, tt := range []struct { + name string + handlers []*recordingHandler + wantErrs []error + }{ + {name: "empty"}, + {name: "all enabled", handlers: []*recordingHandler{{}, {}}}, + {name: "disabled child", handlers: []*recordingHandler{{}, {level: slog.LevelError, err: disabledErr}}}, + {name: "all disabled", handlers: []*recordingHandler{{level: slog.LevelWarn, err: disabledErr}, {level: slog.LevelError, err: disabledErr}}}, + {name: "continues after error", handlers: []*recordingHandler{{err: firstErr}, {}}, wantErrs: []error{firstErr}}, + {name: "joins errors", handlers: []*recordingHandler{{err: firstErr}, {err: secondErr}}, wantErrs: []error{firstErr, secondErr}}, + } { + t.Run(tt.name, func(t *testing.T) { + var h teeHandler + for _, child := range tt.handlers { + h.handlers = append(h.handlers, child) + } + ctx := t.Context() + record := slog.NewRecord(time.Now(), slog.LevelInfo, "test message", 0) + record.AddAttrs(slog.String("key", "value")) + err := h.Handle(ctx, record) + if len(tt.wantErrs) == 0 && err != nil { + t.Errorf("Handle() = %v, want nil", err) + } + for _, want := range tt.wantErrs { + if !errors.Is(err, want) { + t.Errorf("Handle() = %v, want error containing %v", err, want) + } + } + for i, child := range tt.handlers { + var want []slog.Record + if child.Enabled(ctx, record.Level) { + want = []slog.Record{record} + if child.ctx != ctx { + t.Errorf("child %d received a different context", i) + } + } + if diff := testutil.Diff(want, child.records, cmp.AllowUnexported(slog.Record{}, slog.Value{})); diff != "" { + t.Errorf("child %d records mismatch (-want +got):\n%s", i, diff) + } + } + }) + } +} + +func TestTeeHandlerWithAttrsAndGroup(t *testing.T) { + var buffers [2]bytes.Buffer + h := teeHandler{handlers: []slog.Handler{ + slog.NewJSONHandler(&buffers[0], nil), + slog.NewJSONHandler(&buffers[1], nil), + }} + derived := h.WithAttrs([]slog.Attr{slog.String("outer", "value")}).WithGroup("group").WithAttrs([]slog.Attr{slog.Int("inner", 42)}) + record := slog.NewRecord(time.Time{}, slog.LevelInfo, "derived", 0) + record.AddAttrs(slog.Bool("record", true)) + if err := derived.Handle(t.Context(), record); err != nil { + t.Fatal(err) + } + if err := h.Handle(t.Context(), slog.NewRecord(time.Time{}, slog.LevelInfo, "original", 0)); err != nil { + t.Fatal(err) + } + for i := range buffers { + decoder := json.NewDecoder(&buffers[i]) + for _, want := range []map[string]any{ + {"level": "INFO", "msg": "derived", "outer": "value", "group": map[string]any{"inner": float64(42), "record": true}}, + {"level": "INFO", "msg": "original"}, + } { + var got map[string]any + if err := decoder.Decode(&got); err != nil { + t.Fatal(err) + } + if diff := testutil.Diff(want, got); diff != "" { + t.Errorf("child %d output mismatch (-want +got):\n%s", i, diff) + } + } + } +} From 49e953a21d6139c2f1c07c4eb16cc62120db0122 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Tue, 20 Oct 2026 15:47:02 -0400 Subject: [PATCH 2/2] don't send debug logs to Sentry --- internal/logutil/logutil.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/logutil/logutil.go b/internal/logutil/logutil.go index e2c8e438..19d0646f 100644 --- a/internal/logutil/logutil.go +++ b/internal/logutil/logutil.go @@ -23,7 +23,9 @@ func ConfigureLogger() { }, }) // `context.Background()` here will use the global Sentry client. - sentryHandler := sentryslog.Option{}.NewSentryHandler(context.Background()) + sentryHandler := sentryslog.Option{ + LogLevel: []slog.Level{slog.LevelInfo, slog.LevelWarn, slog.LevelError, sentryslog.LevelFatal}, + }.NewSentryHandler(context.Background()) h := teeHandler{handlers: []slog.Handler{jsonHandler, sentryHandler}} slog.SetDefault(slog.New(h)) slog.SetLogLoggerLevel(slog.LevelInfo)