From 18fc01b01a2838b8058176fbad29d2dae5217955 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Sun, 19 Jul 2026 14:46:43 +0200 Subject: [PATCH] feat(observability): enrich Sentry error events with root-cause metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All server errors funnel through LogAndMaskErr, so every Sentry event carries the same capture-site stacktrace and a generic *fmt.wrapError outermost type: issues are indistinguishable. Add a BeforeSend hook in pkg/servicelogger, wired into both the controlplane and artifact-cas binaries, that analyzes the captured error's unwrap chain (depth- and visit-bounded, cycle- and errors.Join-safe) and enriches the event: - Fingerprint: ['{{ default }}', discriminator] where discriminator is SQLSTATE, gRPC code, or kratos reason — extends Sentry's stack-based default grouping with structured root-cause data. Formatted error text is never used for fingerprinting (it embeds request-specific values). Errors without a discriminator keep default grouping. - Tags: error.root_type, error.sqlstate, error.grpc_code, error.kratos_reason, error.chain_depth, error.multi_error - Generic wrapper exception types (*fmt.wrapError et al.) on the primary exception rewritten to the root-cause type so issue titles identify the failure. One hook covers all capture paths (LogAndMaskErr, auditor, future CaptureException calls) with zero callsite changes. Also removes a pre-existing leading-newline lint violation in app/controlplane/cmd/main.go. Signed-off-by: Miguel Martinez Trivino --- app/artifact-cas/cmd/main.go | 1 + app/controlplane/cmd/main.go | 2 +- pkg/servicelogger/sentry_enrich.go | 212 +++++++++++++++ pkg/servicelogger/sentry_enrich_test.go | 344 ++++++++++++++++++++++++ 4 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 pkg/servicelogger/sentry_enrich.go create mode 100644 pkg/servicelogger/sentry_enrich_test.go diff --git a/app/artifact-cas/cmd/main.go b/app/artifact-cas/cmd/main.go index c27a51d5d..b3986301f 100644 --- a/app/artifact-cas/cmd/main.go +++ b/app/artifact-cas/cmd/main.go @@ -167,6 +167,7 @@ func initSentry(c *conf.Bootstrap, logger log.Logger) (cleanupFunc func(), err e Environment: sentryOpts.Environment, Release: Version, AttachStacktrace: true, + BeforeSend: servicelogger.SentryBeforeSend, }) if err == nil { diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go index 6eac03328..38bbc972c 100644 --- a/app/controlplane/cmd/main.go +++ b/app/controlplane/cmd/main.go @@ -180,7 +180,6 @@ func main() { // Start the background CAS Backend checker for DEFAULT backends (every 30 minutes) if app.casBackendChecker != nil { - go app.casBackendChecker.Start(ctx, &biz.CASBackendCheckerOpts{ CheckInterval: 30 * time.Minute, InitialDelay: initialDelay, @@ -288,6 +287,7 @@ func initSentry(c *conf.Bootstrap, logger log.Logger) (cleanupFunc func(), err e Environment: sentryOpts.Environment, Release: Version, AttachStacktrace: true, + BeforeSend: servicelogger.SentryBeforeSend, }) if err == nil { diff --git a/pkg/servicelogger/sentry_enrich.go b/pkg/servicelogger/sentry_enrich.go new file mode 100644 index 000000000..f8ac4b74d --- /dev/null +++ b/pkg/servicelogger/sentry_enrich.go @@ -0,0 +1,212 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package servicelogger + +import ( + "errors" + "reflect" + "strconv" + + "github.com/getsentry/sentry-go" + kerrors "github.com/go-kratos/kratos/v2/errors" + "google.golang.org/grpc/status" +) + +const ( + // maxUnwrapDepth caps the error-chain walk to guard against pathological cycles + maxUnwrapDepth = 32 + // maxChainVisits bounds total DFS visits so wide joins exhaust visits before the depth cap + maxChainVisits = maxUnwrapDepth * 4 +) + +// genericWrapperTypes are concrete error types produced by wrapping helpers +// (fmt.Errorf, pkg/errors) that say nothing about the failure itself. When the +// primary Sentry exception has one of these types it is rewritten to the +// root-cause type so issue titles become meaningful. +var genericWrapperTypes = map[string]struct{}{ + "*fmt.wrapError": {}, + "*fmt.withMessage": {}, + "*errors.withMessage": {}, + "*errors.withStack": {}, + "*errors.fundamental": {}, +} + +// errorChain summarizes an error unwrap chain for Sentry enrichment +type errorChain struct { + rootType string // Go type of the innermost error + depth int + sqlState string // SQLSTATE from pgx/pgconn-style errors + grpcCode string // gRPC status code from status errors + kratosReason string // reason from kratos errors + multi bool // chain contains a multi-error (Unwrap() []error, e.g. errors.Join) +} + +// SentryBeforeSend enriches Sentry error events so issues are distinguishable +// at a glance and group deterministically by failure mode: +// - A structured discriminator extends Sentry's default fingerprint +// - A generic wrapper type on the primary exception is rewritten to the root-cause type +// - Tags expose the root-cause metadata for search +// +// Events without an original error to analyze (messages, transactions) and +// errors without a structured discriminator are left +// untouched, preserving Sentry's default grouping. Joined multi-errors +// (errors.Join) also keep default grouping: bundling independent failures +// makes any single fingerprint misleading, though their metadata is tagged. +func SentryBeforeSend(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + if event == nil || len(event.Exception) == 0 { + return event + } + + original := originalError(hint) + if original == nil { + return event + } + + info := analyzeErrorChain(original) + + // Joined multi-errors bundle independent failures: any single root type or + // fingerprint would arbitrarily pair attributes from different branches, so + // they keep Sentry's default grouping and their factual wrapper type + if !info.multi && len(event.Fingerprint) == 0 { + if fp := fingerprint(info); fp != nil { + event.Fingerprint = fp + } + } + + // sentry-go orders exceptions root-first, so the last entry is the + // outermost (primary) exception used for the issue title + primary := &event.Exception[len(event.Exception)-1] + if _, generic := genericWrapperTypes[primary.Type]; generic && !info.multi { + primary.Type = info.rootType + } + + if event.Tags == nil { + event.Tags = make(map[string]string) + } + event.Tags["error.chain_depth"] = strconv.Itoa(info.depth) + if info.multi { + event.Tags["error.multi_error"] = "true" + } else { + event.Tags["error.root_type"] = info.rootType + } + if info.sqlState != "" { + event.Tags["error.sqlstate"] = info.sqlState + } + if info.grpcCode != "" { + event.Tags["error.grpc_code"] = info.grpcCode + } + if info.kratosReason != "" { + event.Tags["error.kratos_reason"] = info.kratosReason + } + + return event +} + +// originalError extracts the captured error from the hint, covering both +// CaptureException and recovered panics +func originalError(hint *sentry.EventHint) error { + if hint == nil { + return nil + } + + if hint.OriginalException != nil { + return hint.OriginalException + } + + if recovered, ok := hint.RecoveredException.(error); ok { + return recovered + } + + return nil +} + +// analyzeErrorChain walks err's unwrap chain collecting the root-cause type, +// structured discriminators (SQLSTATE, gRPC code, kratos reason). It traverses +// both single (Unwrap() error) and +// multi (Unwrap() []error, e.g. errors.Join) branches like sentry-go does. +// The walk is bounded by depth and a total-visit budget so cyclic chains and +// wide joins terminate. Discriminators are collected via type assertions +// instead of errors.As because the stdlib traversal has no cycle detection. +func analyzeErrorChain(err error) errorChain { + var info errorChain + if err == nil { + return info + } + + budget := maxChainVisits + deepest := err + var visit func(cur error, depth int) + visit = func(cur error, depth int) { + if cur == nil || depth > maxUnwrapDepth || budget <= 0 { + return + } + budget-- + + if depth > info.depth { + info.depth = depth + deepest = cur + } + + if se, ok := cur.(interface{ SQLState() string }); ok && info.sqlState == "" { + info.sqlState = se.SQLState() + } + if ge, ok := cur.(interface{ GRPCStatus() *status.Status }); ok && info.grpcCode == "" { + // nil-check: third-party implementations may return a nil status + if s := ge.GRPCStatus(); s != nil { + info.grpcCode = s.Code().String() + } + } + if ke, ok := cur.(*kerrors.Error); ok && info.kratosReason == "" { //nolint:errorlint // per-node assertion by design: errors.As has no cycle protection + info.kratosReason = ke.Reason + } + + if multi, ok := cur.(interface{ Unwrap() []error }); ok { + info.multi = true + for _, child := range multi.Unwrap() { + visit(child, depth+1) + } + return + } + visit(errors.Unwrap(cur), depth+1) + } + visit(err, 0) + + info.rootType = reflect.TypeOf(deepest).String() + return info +} + +// fingerprint extends Sentry's default grouping with a structured discriminator. +// `{{ default }}` keeps Sentry's grouping heuristics (stacktrace, message) and +// adds a SQLSTATE/gRPC/kratos subdivision. Call-site separation is not +// guaranteed: AttachStacktrace captures the shared LogAndMaskErr frame, so the +// type rewrite and default message heuristics are what actually distinguish +// events. Formatted error text is deliberately excluded from the fingerprint +// because it may contain request-specific values that cause cardinality. +func fingerprint(info errorChain) []string { + discriminator := info.sqlState + if discriminator == "" { + discriminator = info.grpcCode + } + if discriminator == "" { + discriminator = info.kratosReason + } + + if discriminator != "" { + return []string{"{{ default }}", discriminator} + } + + return nil +} diff --git a/pkg/servicelogger/sentry_enrich_test.go b/pkg/servicelogger/sentry_enrich_test.go new file mode 100644 index 000000000..a97b856eb --- /dev/null +++ b/pkg/servicelogger/sentry_enrich_test.go @@ -0,0 +1,344 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package servicelogger_test + +import ( + "errors" + "fmt" + "strconv" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/servicelogger" + "github.com/getsentry/sentry-go" + kerrors "github.com/go-kratos/kratos/v2/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fakeStateError mimics pgconn.PgError's SQLState() string interface +type fakeStateError struct{ code string } + +func (e *fakeStateError) Error() string { return "duplicate key value violates unique constraint" } +func (e *fakeStateError) SQLState() string { return e.code } + +// cycleError builds cyclic unwrap chains +type cycleError struct { + msg string + next *cycleError +} + +func (e *cycleError) Error() string { return e.msg } +func (e *cycleError) Unwrap() error { + if e.next == nil { + return nil + } + return e.next +} + +// sqlAndGRPCError implements both SQLState and GRPCStatus to pin discriminator precedence +type sqlAndGRPCError struct{} + +func (e *sqlAndGRPCError) Error() string { return "db unavailable" } +func (e *sqlAndGRPCError) SQLState() string { return "08006" } +func (e *sqlAndGRPCError) GRPCStatus() *status.Status { + return status.New(codes.Unavailable, "db unavailable") +} + +// nilStatusError implements GRPCStatus but returns nil +type nilStatusError struct{} + +func (e *nilStatusError) Error() string { return "nil status" } +func (e *nilStatusError) GRPCStatus() *status.Status { return nil } + +func newErrorEvent(exceptions ...sentry.Exception) *sentry.Event { + return &sentry.Event{Exception: exceptions} +} + +// primary returns the last (outermost) exception, the one Sentry uses for the issue title +func primary(event *sentry.Event) sentry.Exception { + return event.Exception[len(event.Exception)-1] +} + +func TestSentryBeforeSend(t *testing.T) { + t.Run("wrapped SQLSTATE error gets discriminator fingerprint", func(t *testing.T) { + root := &fakeStateError{code: "23505"} + err := fmt.Errorf("creating version: %w", root) + event := newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.fakeStateError", Value: root.Error()}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + require.NotNil(t, got) + + assert.Equal(t, []string{"{{ default }}", "23505"}, got.Fingerprint) + // generic wrapper type rewritten to the root-cause type + assert.Equal(t, "*servicelogger_test.fakeStateError", primary(got).Type) + assert.Equal(t, map[string]string{ + "error.root_type": "*servicelogger_test.fakeStateError", + "error.chain_depth": "1", + "error.sqlstate": "23505", + }, got.Tags) + }) + + t.Run("nested wraps report correct chain depth", func(t *testing.T) { + err := fmt.Errorf("storing attestation: %w", fmt.Errorf("querying db: %w", errors.New("connection reset"))) + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "connection reset"}, + sentry.Exception{Type: "*fmt.wrapError", Value: "querying db: connection reset"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + // no structured discriminator → default grouping; chain depth still tagged + assert.Empty(t, got.Fingerprint) + assert.Equal(t, "2", got.Tags["error.chain_depth"]) + }) + + t.Run("wrapped gRPC status error discriminates by code", func(t *testing.T) { + err := fmt.Errorf("creating the gRPC client: %w", status.Error(codes.DeadlineExceeded, "context deadline exceeded")) + event := newErrorEvent( + sentry.Exception{Type: "*status.Error", Value: "rpc error: code = DeadlineExceeded desc = context deadline exceeded"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Equal(t, []string{"{{ default }}", "DeadlineExceeded"}, got.Fingerprint) + assert.Equal(t, "DeadlineExceeded", got.Tags["error.grpc_code"]) + }) + + t.Run("wrapped kratos error tags reason and gRPC code", func(t *testing.T) { + kratosErr := kerrors.New(500, "db unavailable", "database is closed") + err := fmt.Errorf("loading integration info: %w", kratosErr) + event := newErrorEvent( + sentry.Exception{Type: "*errors.Error", Value: kratosErr.Error()}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Equal(t, []string{"{{ default }}", "Internal"}, got.Fingerprint) + assert.Equal(t, "Internal", got.Tags["error.grpc_code"]) + assert.Equal(t, "db unavailable", got.Tags["error.kratos_reason"]) + }) + + t.Run("bare kratos error groups by reason", func(t *testing.T) { + err := kerrors.New(500, "internal error", "server error") + event := newErrorEvent( + sentry.Exception{Type: "*errors.Error", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Equal(t, []string{"{{ default }}", "Internal"}, got.Fingerprint) + // non-generic type is not rewritten + assert.Equal(t, "*errors.Error", primary(got).Type) + }) + + t.Run("plain unwrapped error keeps default grouping", func(t *testing.T) { + err := errors.New("boom") + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "boom"}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Empty(t, got.Fingerprint) + // tags are still attached for searchability + assert.Equal(t, "*errors.errorString", got.Tags["error.root_type"]) + assert.Equal(t, "0", got.Tags["error.chain_depth"]) + }) + + t.Run("error without structured discriminator keeps default grouping", func(t *testing.T) { + err := fmt.Errorf("doing work: %w", errors.New("root cause")) + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "root cause"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Empty(t, got.Fingerprint) + }) + + t.Run("nil GRPCStatus implementation does not panic", func(t *testing.T) { + err := fmt.Errorf("calling upstream: %w", &nilStatusError{}) + event := newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.nilStatusError", Value: "nil status"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + // nil status → no grpc code → no discriminator → default grouping + assert.Empty(t, got.Fingerprint) + _, hasGRPCCode := got.Tags["error.grpc_code"] + assert.False(t, hasGRPCCode) + }) + + t.Run("cyclic chain terminates", func(t *testing.T) { + a := &cycleError{msg: "a"} + b := &cycleError{msg: "b"} + a.next, b.next = b, a + event := newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.cycleError", Value: "a"}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: a}) + + // the observable contract is termination with a bounded depth + depth, err := strconv.Atoi(got.Tags["error.chain_depth"]) + require.NoError(t, err) + // 32 mirrors the unexported maxUnwrapDepth constant in the production + // package (external test package cannot reference it) + assert.LessOrEqual(t, depth, 32) + }) + + t.Run("recovered panic error is analyzed", func(t *testing.T) { + err := fmt.Errorf("booting server: %w", errors.New("out of fds")) + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "out of fds"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{RecoveredException: err}) + + // no structured discriminator → default grouping, but tags are set + assert.Empty(t, got.Fingerprint) + assert.Equal(t, "*errors.errorString", got.Tags["error.root_type"]) + }) + + t.Run("existing tags are preserved", func(t *testing.T) { + err := fmt.Errorf("creating version: %w", errors.New("conflict")) + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "conflict"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + event.Tags = map[string]string{"request_id": "abc"} + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Equal(t, "abc", got.Tags["request_id"]) + assert.Equal(t, "*errors.errorString", got.Tags["error.root_type"]) + }) + + t.Run("joined multi-errors keep default grouping but are tagged", func(t *testing.T) { + err := fmt.Errorf("reconciling: %w", errors.Join( + errors.New("first failure"), + &fakeStateError{code: "23505"}, + )) + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "first failure"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + // joined failures are ambiguous: no single root defines them, so no + // fingerprint or type rewrite; metadata from all branches is tagged + assert.Empty(t, got.Fingerprint) + assert.Equal(t, "*fmt.wrapError", primary(got).Type) + assert.Equal(t, "23505", got.Tags["error.sqlstate"]) + assert.Equal(t, "true", got.Tags["error.multi_error"]) + _, hasRootType := got.Tags["error.root_type"] + assert.False(t, hasRootType) + }) + + t.Run("unwrapped SQLSTATE error gets discriminator fingerprint", func(t *testing.T) { + err := &fakeStateError{code: "23505"} + event := newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.fakeStateError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + // SQLSTATE is a structured discriminator → fingerprint extends default grouping + assert.Equal(t, []string{"{{ default }}", "23505"}, got.Fingerprint) + assert.Equal(t, "23505", got.Tags["error.sqlstate"]) + }) + + t.Run("same root type with different dynamic values groups together", func(t *testing.T) { + root := &fakeStateError{code: "23505"} + makeEvent := func(email string) *sentry.Event { + err := fmt.Errorf("error finding user %s: %w", email, root) + return newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.fakeStateError", Value: root.Error()}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + } + + first := servicelogger.SentryBeforeSend(makeEvent("alice@corp.com"), &sentry.EventHint{OriginalException: fmt.Errorf("error finding user %s: %w", "alice@corp.com", root)}) + second := servicelogger.SentryBeforeSend(makeEvent("bob@other.io"), &sentry.EventHint{OriginalException: fmt.Errorf("error finding user %s: %w", "bob@other.io", root)}) + + // the fingerprint uses only the structured discriminator, not message + // text, so different request-specific values produce the same fingerprint + assert.Equal(t, first.Fingerprint, second.Fingerprint) + }) + + t.Run("SQLSTATE takes precedence over gRPC code as discriminator", func(t *testing.T) { + err := fmt.Errorf("reconnecting: %w", &sqlAndGRPCError{}) + event := newErrorEvent( + sentry.Exception{Type: "*servicelogger_test.sqlAndGRPCError", Value: "db unavailable"}, + sentry.Exception{Type: "*fmt.wrapError", Value: err.Error()}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: err}) + + assert.Equal(t, []string{"{{ default }}", "08006"}, got.Fingerprint) + assert.Equal(t, "08006", got.Tags["error.sqlstate"]) + assert.Equal(t, "Unavailable", got.Tags["error.grpc_code"]) + }) + + t.Run("non-error recovered panic is left untouched", func(t *testing.T) { + event := newErrorEvent( + sentry.Exception{Type: "*errors.errorString", Value: "boom"}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{RecoveredException: "boom"}) + + assert.Empty(t, got.Fingerprint) + assert.Empty(t, got.Tags) + }) + + t.Run("event without original error is left untouched", func(t *testing.T) { + event := newErrorEvent( + sentry.Exception{Type: "*fmt.wrapError", Value: "creating version: boom"}, + ) + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{}) + + assert.Empty(t, got.Fingerprint) + assert.Empty(t, got.Tags) + assert.Equal(t, "*fmt.wrapError", primary(got).Type) + }) + + t.Run("event without exceptions is left untouched", func(t *testing.T) { + event := &sentry.Event{Message: "a log message"} + + got := servicelogger.SentryBeforeSend(event, &sentry.EventHint{OriginalException: errors.New("boom")}) + + assert.Equal(t, "a log message", got.Message) + assert.Empty(t, got.Fingerprint) + }) + + t.Run("nil event does not panic", func(t *testing.T) { + assert.Nil(t, servicelogger.SentryBeforeSend(nil, nil)) + }) +}