From d28e952356afd80ff8793b8d6d5eed468cf2a2ab Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 27 Oct 2026 17:54:27 +0100 Subject: [PATCH 1/2] feat(errorsx): add database and gRPC error stack hooks Add shared origin-stack error helpers and wrap the control plane Ent driver so query, transaction, and row-read errors retain their origin stacks. Keep schema loading on a plain driver and let Ping work through wrapped drivers. Allow grpcconn clients to install unary and stream interceptors. Refs PFM-8040 Closes #3735 Assisted-by: pi Signed-off-by: Miguel Martinez Trivino --- app/controlplane/pkg/data/data.go | 9 +- app/controlplane/pkg/data/ent/ping.go | 17 ++- app/controlplane/pkg/data/ent/ping_test.go | 41 +++++++ pkg/entdriver/stackdriver.go | 129 +++++++++++++++++++++ pkg/entdriver/stackdriver_test.go | 123 ++++++++++++++++++++ pkg/errorsx/errorsx.go | 98 ++++++++++++++++ pkg/errorsx/errorsx_test.go | 117 +++++++++++++++++++ pkg/errorsx/errorsxtest/errorsxtest.go | 63 ++++++++++ pkg/grpcconn/grpcconn.go | 33 ++++-- pkg/grpcconn/grpcconn_test.go | 43 +++++++ 10 files changed, 660 insertions(+), 13 deletions(-) create mode 100644 app/controlplane/pkg/data/ent/ping_test.go create mode 100644 pkg/entdriver/stackdriver.go create mode 100644 pkg/entdriver/stackdriver_test.go create mode 100644 pkg/errorsx/errorsx.go create mode 100644 pkg/errorsx/errorsx_test.go create mode 100644 pkg/errorsx/errorsxtest/errorsxtest.go diff --git a/app/controlplane/pkg/data/data.go b/app/controlplane/pkg/data/data.go index c5240548f..36f7a263c 100644 --- a/app/controlplane/pkg/data/data.go +++ b/app/controlplane/pkg/data/data.go @@ -28,6 +28,7 @@ import ( config "github.com/chainloop-dev/chainloop/app/controlplane/pkg/conf/controlplane/config/v1" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent/organization" + "github.com/chainloop-dev/chainloop/pkg/entdriver" "github.com/go-kratos/kratos/v2/log" "github.com/google/uuid" "github.com/google/wire" @@ -76,7 +77,11 @@ type Data struct { // NOTE: this is different than running migrations // this method is used to load the schema into the DB for TESTING! func (data *Data) SchemaLoad() error { - return data.DB.Schema.Create(context.Background()) + // ent's Atlas schema code casts the rows handle to *sql.Rows, which the + // stack-recording rows of StackDriver are not, so load with a plain driver. + plain := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, data.SQLDB))) + + return plain.Schema.Create(context.Background()) } // NewData . @@ -140,7 +145,7 @@ func initSQLDatabase(c *config.DatabaseConfig, tp trace.TracerProvider, log *log db.SetConnMaxIdleTime(t) } - drv := entsql.OpenDB(dialect.Postgres, db) + drv := entdriver.NewStackDriver(entsql.OpenDB(dialect.Postgres, db)) client := ent.NewClient(ent.Driver(drv)) // NOTE: We do not run migrations automatically anymore diff --git a/app/controlplane/pkg/data/ent/ping.go b/app/controlplane/pkg/data/ent/ping.go index 51b10f818..0cdce045f 100644 --- a/app/controlplane/pkg/data/ent/ping.go +++ b/app/controlplane/pkg/data/ent/ping.go @@ -1,5 +1,5 @@ // -// Copyright 2023 The Chainloop Authors. +// Copyright 2023-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. @@ -15,9 +15,18 @@ package ent -import "entgo.io/ent/dialect/sql" +import ( + "database/sql" + "fmt" +) func (c *Client) Ping() error { - db := c.driver.(*sql.Driver).DB() - return db.Ping() + // The driver can be wrapped (for example to record error stacks), so ask + // for the method instead of the concrete *sql.Driver type. + d, ok := c.driver.(interface{ DB() *sql.DB }) + if !ok { + return fmt.Errorf("ping: driver %T does not expose a database", c.driver) + } + + return d.DB().Ping() } diff --git a/app/controlplane/pkg/data/ent/ping_test.go b/app/controlplane/pkg/data/ent/ping_test.go new file mode 100644 index 000000000..fc4045a25 --- /dev/null +++ b/app/controlplane/pkg/data/ent/ping_test.go @@ -0,0 +1,41 @@ +// +// 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 ent + +import ( + "database/sql" + "testing" + + "entgo.io/ent/dialect" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/require" +) + +type driverWithDB struct { + dialect.Driver + db *sql.DB +} + +func (d *driverWithDB) DB() *sql.DB { return d.db } + +func TestPingWithWrappedDriver(t *testing.T) { + db, err := sql.Open("pgx", "postgres://localhost:1/unused") + require.NoError(t, err) + require.NoError(t, db.Close()) + + client := NewClient(Driver(&driverWithDB{db: db})) + require.EqualError(t, client.Ping(), "sql: database is closed") +} diff --git a/pkg/entdriver/stackdriver.go b/pkg/entdriver/stackdriver.go new file mode 100644 index 000000000..de6026c14 --- /dev/null +++ b/pkg/entdriver/stackdriver.go @@ -0,0 +1,129 @@ +// +// 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 entdriver provides ent driver wrappers. +package entdriver + +import ( + "context" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/chainloop-dev/chainloop/pkg/errorsx" +) + +// StackDriver is an ent driver that records the stack of the caller on each +// database error. The stack holds the repository method that ran the query, +// so Sentry groups a database failure by the query that failed. +// Do not run ent's schema API (Schema.Create, Diff, WriteTo) through it: that +// code casts the result rows to *sql.Rows, which stackRows is not. +type StackDriver struct { + // Driver is the ent driver that runs the queries. + *entsql.Driver +} + +// NewStackDriver wraps driver so database errors record the caller's stack. +func NewStackDriver(driver *entsql.Driver) *StackDriver { + return &StackDriver{Driver: driver} +} + +// Exec runs a statement and records the stack of the caller on error. +func (d *StackDriver) Exec(ctx context.Context, query string, args, v any) error { + return errorsx.WithStack(d.Driver.Exec(ctx, query, args, v)) +} + +// Query runs a query and records the stack of the caller on error. +func (d *StackDriver) Query(ctx context.Context, query string, args, v any) error { + return queryResult(d.Driver.Query(ctx, query, args, v), v) +} + +// Tx starts a transaction whose errors record the stack of the caller. +func (d *StackDriver) Tx(ctx context.Context) (dialect.Tx, error) { + return d.BeginTx(ctx, nil) +} + +// BeginTx starts a transaction with options. The generated ent client calls it +// through an interface check, so the wrapper must keep it. +func (d *StackDriver) BeginTx(ctx context.Context, opts *entsql.TxOptions) (dialect.Tx, error) { + tx, err := d.Driver.BeginTx(ctx, opts) + if err != nil { + return nil, errorsx.WithStack(err) + } + + return &stackTx{Tx: tx}, nil +} + +// stackTx is a transaction that records the stack of the caller on each error. +type stackTx struct { + // Tx is the ent transaction that runs the queries. + dialect.Tx +} + +// Exec runs a statement and records the stack of the caller on error. +func (t *stackTx) Exec(ctx context.Context, query string, args, v any) error { + return errorsx.WithStack(t.Tx.Exec(ctx, query, args, v)) +} + +// Query runs a query and records the stack of the caller on error. +func (t *stackTx) Query(ctx context.Context, query string, args, v any) error { + return queryResult(t.Tx.Query(ctx, query, args, v), v) +} + +// Commit commits the transaction and records the stack of the caller on error. +func (t *stackTx) Commit() error { + return errorsx.WithStack(t.Tx.Commit()) +} + +// Rollback aborts the transaction and records the stack of the caller on error. +func (t *stackTx) Rollback() error { + return errorsx.WithStack(t.Tx.Rollback()) +} + +// queryResult records the stack of the caller on a query error. On success it +// also wraps the result rows: Query returns once Postgres accepts the query, and +// ent then reads the rows through the rows handle, not through the driver. A +// failure during that read (a dropped connection, a timeout in a large result) +// would otherwise reach Sentry with no stack. +func queryResult(err error, v any) error { + if err != nil { + return errorsx.WithStack(err) + } + if rows, ok := v.(*entsql.Rows); ok { + rows.ColumnScanner = stackRows{ColumnScanner: rows.ColumnScanner} + } + + return nil +} + +// stackRows records the stack of the caller on each error while it reads the result rows. +type stackRows struct { + // ColumnScanner is the rows handle of the query. + entsql.ColumnScanner +} + +// Scan copies the current row and records the stack of the caller on error. +func (r stackRows) Scan(dest ...any) error { + return errorsx.WithStack(r.ColumnScanner.Scan(dest...)) +} + +// Err returns the error of the row iteration with the stack of the caller. +func (r stackRows) Err() error { + return errorsx.WithStack(r.ColumnScanner.Err()) +} + +// Close closes the rows and records the stack of the caller on error. +func (r stackRows) Close() error { + return errorsx.WithStack(r.ColumnScanner.Close()) +} diff --git a/pkg/entdriver/stackdriver_test.go b/pkg/entdriver/stackdriver_test.go new file mode 100644 index 000000000..210233ab3 --- /dev/null +++ b/pkg/entdriver/stackdriver_test.go @@ -0,0 +1,123 @@ +// +// 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 entdriver + +import ( + "context" + "database/sql" + "errors" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/chainloop-dev/chainloop/pkg/errorsx" + "github.com/chainloop-dev/chainloop/pkg/errorsx/errorsxtest" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/suite" +) + +type StackDriverSuite struct { + suite.Suite + drv *StackDriver +} + +func TestStackDriver(t *testing.T) { + suite.Run(t, new(StackDriverSuite)) +} + +// errDBClosed is the message of the unexported database/sql error that a +// closed database returns, as when the backend shuts down. +const errDBClosed = "sql: database is closed" + +// SetupTest opens a closed database, so that every query fails. +func (s *StackDriverSuite) SetupTest() { + db, err := sql.Open("pgx", "postgres://localhost:1/unused") + s.Require().NoError(err) + s.Require().NoError(db.Close()) + + s.drv = NewStackDriver(entsql.OpenDB(dialect.Postgres, db)) +} + +func queryAtOrigin(ctx context.Context, drv dialect.ExecQuerier) error { + var rows entsql.Rows + return drv.Query(ctx, "SELECT 1", []any{}, &rows) +} + +func execAtOrigin(ctx context.Context, drv dialect.ExecQuerier) error { + return drv.Exec(ctx, "SELECT 1", []any{}, nil) +} + +// A database error records the stack of the query that failed, so that Sentry +// groups database failures by query instead of by the code that reports them. +// The message does not change, so logs and error checks do not change. +func (s *StackDriverSuite) TestDriverErrorsRecordCallerStack() { + testCases := []struct { + name string + originFunc string + runAtOrigin func(context.Context, dialect.ExecQuerier) error + }{ + {name: "query", originFunc: "queryAtOrigin", runAtOrigin: queryAtOrigin}, + {name: "exec", originFunc: "execAtOrigin", runAtOrigin: execAtOrigin}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + err := tc.runAtOrigin(context.Background(), s.drv) + s.Require().EqualError(err, errDBClosed) + s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), tc.originFunc) + }) + } +} + +// failingRows is a rows handle whose reads fail, as when the connection drops mid-result. +type failingRows struct { + entsql.ColumnScanner + err error +} + +func (r failingRows) Scan(...any) error { return r.err } +func (r failingRows) Err() error { return r.err } +func (r failingRows) Close() error { return nil } + +func scanAtOrigin(rows *entsql.Rows) error { return rows.Scan() } + +func iterateAtOrigin(rows *entsql.Rows) error { return rows.Err() } + +// A failure while ent reads the result rows from Postgres records the stack of +// the reader, like a failure of the query itself. The message does not change. +func (s *StackDriverSuite) TestRowReadErrorsRecordCallerStack() { + readErr := errors.New("unexpected EOF") + rows := &entsql.Rows{ColumnScanner: failingRows{err: readErr}} + s.Require().NoError(queryResult(nil, rows)) + + err := scanAtOrigin(rows) + s.Require().ErrorIs(err, readErr) + s.EqualError(err, readErr.Error()) + s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), "scanAtOrigin") + + err = iterateAtOrigin(rows) + s.Require().ErrorIs(err, readErr) + s.Contains(errorsxtest.OriginStack(errorsxtest.ErrorExceptions(err)), "iterateAtOrigin") + + s.NoError(rows.Close(), "a successful Close stays nil") +} + +// A transaction that cannot start records the stack of its caller too. +func (s *StackDriverSuite) TestTxErrorRecordsStack() { + _, err := s.drv.Tx(context.Background()) + s.Require().EqualError(err, errDBClosed) + s.True(errorsx.HasStack(err)) +} diff --git a/pkg/errorsx/errorsx.go b/pkg/errorsx/errorsx.go new file mode 100644 index 000000000..85f8b2306 --- /dev/null +++ b/pkg/errorsx/errorsx.go @@ -0,0 +1,98 @@ +// +// 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 errorsx creates errors that record the call stack where they start. +// +// Go errors carry no stack, so sentry-go gives an event the stack of the +// CaptureException call. Every failure reported from one place then shares one +// stack, and Sentry groups unrelated causes into one issue. An error from this +// package exposes its origin stack through StackTrace() []uintptr, which +// sentry-go reads, so Sentry groups the event by where the failure started. +// +// Use New or Errorf where an error starts, and WithStack where an error from +// an external library enters the backend. Keep package-level sentinel errors +// as errors.New: a stack recorded at program start has no value. +// +// github.com/pkg/errors is not used: it is archived, its Errorf does not +// support %w, and its Wrap records a new stack on each wrap. +package errorsx + +import ( + "errors" + "fmt" + "runtime" + "slices" +) + +const maxStackDepth = 32 + +// stackTracer is the method that sentry-go reads to get the stack of an error. +type stackTracer interface { + StackTrace() []uintptr +} + +// withStack is an error with the call stack of the place that created it. +type withStack struct { + // err is the error that this value adds a stack to. + err error + // stack holds the program counters of the call stack, innermost first. + stack []uintptr +} + +func (e *withStack) Error() string { return e.err.Error() } + +func (e *withStack) Unwrap() error { return e.err } + +// StackTrace returns the program counters of the origin stack, innermost first. +func (e *withStack) StackTrace() []uintptr { return e.stack } + +// New returns an error with the message msg and the stack of the caller. +func New(msg string) error { + return wrap(errors.New(msg)) +} + +// Errorf formats like fmt.Errorf, %w included. It records the stack of the +// caller, unless a wrapped error already has a stack: the origin stack is the +// one that tells failures apart. +func Errorf(format string, args ...any) error { + return wrap(fmt.Errorf(format, args...)) +} + +// WithStack adds the stack of the caller to err. It returns err unchanged when +// err is nil or already has a stack. +func WithStack(err error) error { + return wrap(err) +} + +// HasStack reports whether err, or an error that it wraps, records a stack. +func HasStack(err error) bool { + var st stackTracer + + return errors.As(err, &st) +} + +// wrap must be called directly from an exported function of this package: +// the stack it records starts at the caller of that function. +func wrap(err error) error { + if err == nil || HasStack(err) { + return err + } + + var pcs [maxStackDepth]uintptr + // Skip runtime.Callers, wrap and the exported function of this package. + n := runtime.Callers(3, pcs[:]) + + return &withStack{err: err, stack: slices.Clone(pcs[:n])} +} diff --git a/pkg/errorsx/errorsx_test.go b/pkg/errorsx/errorsx_test.go new file mode 100644 index 000000000..e40e90445 --- /dev/null +++ b/pkg/errorsx/errorsx_test.go @@ -0,0 +1,117 @@ +// +// 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 errorsx + +import ( + "errors" + "fmt" + "io" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/errorsx/errorsxtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type customErr struct{ code int } + +func (e *customErr) Error() string { return fmt.Sprintf("custom %d", e.code) } + +func newAtOrigin() error { return New("boom") } + +func errorfAtOrigin(err error) error { return Errorf("doing x: %w", err) } + +func wrapAtBoundary(err error) error { return WithStack(err) } + +func TestNewRecordsOriginStack(t *testing.T) { + err := newAtOrigin() + + assert.EqualError(t, err, "boom") + assert.Equal(t, "newAtOrigin", errorsxtest.ErrorOriginFunction(err)) +} + +func TestErrorfRecordsOriginStackAndWraps(t *testing.T) { + err := errorfAtOrigin(io.EOF) + + assert.EqualError(t, err, "doing x: EOF") + require.ErrorIs(t, err, io.EOF) + assert.Equal(t, "errorfAtOrigin", errorsxtest.ErrorOriginFunction(err)) +} + +func TestErrorfWithoutWrappedError(t *testing.T) { + err := Errorf("value %d", 3) + + assert.EqualError(t, err, "value 3") + assert.Equal(t, "TestErrorfWithoutWrappedError", errorsxtest.ErrorOriginFunction(err)) +} + +func TestErrorfKeepsInnerStack(t *testing.T) { + inner := newAtOrigin() + err := Errorf("outer: %w", inner) + + assert.EqualError(t, err, "outer: boom") + require.ErrorIs(t, err, inner) + assert.IsType(t, fmt.Errorf("x: %w", io.EOF), err, "Errorf must not record a second stack") + assert.Equal(t, "newAtOrigin", errorsxtest.ErrorOriginFunction(err)) +} + +func TestWithStack(t *testing.T) { + t.Run("nil stays nil", func(t *testing.T) { + assert.NoError(t, WithStack(nil)) + }) + + t.Run("records the stack of the boundary", func(t *testing.T) { + err := wrapAtBoundary(io.ErrUnexpectedEOF) + + assert.EqualError(t, err, io.ErrUnexpectedEOF.Error()) + require.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Equal(t, "wrapAtBoundary", errorsxtest.ErrorOriginFunction(err)) + }) + + t.Run("keeps errors.As working", func(t *testing.T) { + err := wrapAtBoundary(&customErr{code: 7}) + + var target *customErr + require.ErrorAs(t, err, &target) + assert.Equal(t, 7, target.code) + }) + + t.Run("does not record a second stack", func(t *testing.T) { + inner := newAtOrigin() + wrapped := fmt.Errorf("mid: %w", inner) + + assert.Same(t, wrapped, WithStack(wrapped)) + }) +} + +func TestHasStack(t *testing.T) { + testCases := []struct { + name string + err error + want bool + }{ + {name: "plain error", err: errors.New("plain"), want: false}, + {name: "nil", err: nil, want: false}, + {name: "origin error", err: newAtOrigin(), want: true}, + {name: "wrapped origin error", err: fmt.Errorf("wrapped: %w", newAtOrigin()), want: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, HasStack(tc.err)) + }) + } +} diff --git a/pkg/errorsx/errorsxtest/errorsxtest.go b/pkg/errorsx/errorsxtest/errorsxtest.go new file mode 100644 index 000000000..332eeef64 --- /dev/null +++ b/pkg/errorsx/errorsxtest/errorsxtest.go @@ -0,0 +1,63 @@ +// +// 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 errorsxtest helps tests check the origin stack that Sentry records for an error. +package errorsxtest + +import "github.com/getsentry/sentry-go" + +// OriginStack returns the functions of the first stack in exceptions, innermost +// first. sentry-go orders exceptions root-first, so an origin stack comes before +// the stack of the capture site. It returns nil when no exception has a stack. +func OriginStack(exceptions []sentry.Exception) []string { + for _, exc := range exceptions { + if exc.Stacktrace == nil || len(exc.Stacktrace.Frames) == 0 { + continue + } + + // Sentry frames are outermost first. + frames := exc.Stacktrace.Frames + fns := make([]string, 0, len(frames)) + for i := len(frames) - 1; i >= 0; i-- { + fns = append(fns, frames[i].Function) + } + + return fns + } + + return nil +} + +// OriginFunction returns the innermost function of OriginStack, or "". +func OriginFunction(exceptions []sentry.Exception) string { + if stack := OriginStack(exceptions); len(stack) > 0 { + return stack[0] + } + + return "" +} + +// ErrorExceptions returns the exceptions that Sentry records for err. +func ErrorExceptions(err error) []sentry.Exception { + event := sentry.NewEvent() + event.SetException(err, 10) + + return event.Exception +} + +// ErrorOriginFunction returns OriginFunction of the exceptions that Sentry records for err. +func ErrorOriginFunction(err error) string { + return OriginFunction(ErrorExceptions(err)) +} diff --git a/pkg/grpcconn/grpcconn.go b/pkg/grpcconn/grpcconn.go index b5b2dc3bb..e548e5319 100644 --- a/pkg/grpcconn/grpcconn.go +++ b/pkg/grpcconn/grpcconn.go @@ -44,13 +44,15 @@ const DefaultMaxRecvMsgSize = 32 * 1024 * 1024 const CLIVersionHeader = "Chainloop-Cli-Version" type newOptionalArg struct { - caFilePath string - caContent string - insecure bool - orgName string - cliVersion string - maxRecvMsgSize int - tokenProvider TokenProvider + caFilePath string + caContent string + insecure bool + orgName string + cliVersion string + maxRecvMsgSize int + tokenProvider TokenProvider + unaryInterceptors []grpc.UnaryClientInterceptor + streamInterceptors []grpc.StreamClientInterceptor } // TokenProvider returns the bearer token for the next request. Use it when the @@ -108,6 +110,20 @@ func WithMaxRecvMsgSize(size int) Option { } } +// WithUnaryInterceptors appends interceptors to the connection's unary client chain. +func WithUnaryInterceptors(interceptors ...grpc.UnaryClientInterceptor) Option { + return func(opt *newOptionalArg) { + opt.unaryInterceptors = append(opt.unaryInterceptors, interceptors...) + } +} + +// WithStreamInterceptors appends interceptors to the connection's stream client chain. +func WithStreamInterceptors(interceptors ...grpc.StreamClientInterceptor) Option { + return func(opt *newOptionalArg) { + opt.streamInterceptors = append(opt.streamInterceptors, interceptors...) + } +} + func cliVersionUnaryInterceptor(version string) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { ctx = metadata.AppendToOutgoingContext(ctx, CLIVersionHeader, version) @@ -146,6 +162,9 @@ func New(uri, authToken string, opt ...Option) (*grpc.ClientConn, error) { streamInterceptors = append(streamInterceptors, cliVersionStreamInterceptor(optionalArgs.cliVersion)) } + unaryInterceptors = append(unaryInterceptors, optionalArgs.unaryInterceptors...) + streamInterceptors = append(streamInterceptors, optionalArgs.streamInterceptors...) + if len(unaryInterceptors) > 0 { opts = append(opts, grpc.WithChainUnaryInterceptor(unaryInterceptors...)) } diff --git a/pkg/grpcconn/grpcconn_test.go b/pkg/grpcconn/grpcconn_test.go index c59654f53..823c05dc0 100644 --- a/pkg/grpcconn/grpcconn_test.go +++ b/pkg/grpcconn/grpcconn_test.go @@ -327,3 +327,46 @@ func TestNewWithTokenProviderSendsTokenPerCall(t *testing.T) { assert.Equal(t, []string{"Bearer token-1", "Bearer token-2"}, gotAuth) } + +func TestNewWithClientInterceptors(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := grpc.NewServer() + healthpb.RegisterHealthServer(srv, health.NewServer()) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + var unaryCalls, streamCalls int + unaryInterceptor := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + unaryCalls++ + return invoker(ctx, method, req, reply, cc, opts...) + } + streamInterceptor := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + streamCalls++ + return streamer(ctx, desc, cc, method, opts...) + } + + conn, err := New( + lis.Addr().String(), + "", + WithInsecure(true), + WithUnaryInterceptors(unaryInterceptor), + WithStreamInterceptors(streamInterceptor), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + client := healthpb.NewHealthClient(conn) + _, err = client.Check(context.Background(), &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + stream, err := client.Watch(ctx, &healthpb.HealthCheckRequest{}) + require.NoError(t, err) + require.NoError(t, stream.CloseSend()) + + assert.Equal(t, 1, unaryCalls) + assert.Equal(t, 1, streamCalls) +} From ec1b8d8379ba0863202c8d3ea6cf4d87123ff154 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 27 Oct 2026 18:14:35 +0100 Subject: [PATCH 2/2] fix(errorsx): match Sentry error depth in tests Use the sentry-go default error depth in ErrorExceptions and cover chains longer than ten errors. Assisted-by: pi Signed-off-by: Miguel Martinez Trivino --- pkg/errorsx/errorsxtest/errorsxtest.go | 3 +- pkg/errorsx/errorsxtest/errorsxtest_test.go | 33 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 pkg/errorsx/errorsxtest/errorsxtest_test.go diff --git a/pkg/errorsx/errorsxtest/errorsxtest.go b/pkg/errorsx/errorsxtest/errorsxtest.go index 332eeef64..f6037499a 100644 --- a/pkg/errorsx/errorsxtest/errorsxtest.go +++ b/pkg/errorsx/errorsxtest/errorsxtest.go @@ -52,7 +52,8 @@ func OriginFunction(exceptions []sentry.Exception) string { // ErrorExceptions returns the exceptions that Sentry records for err. func ErrorExceptions(err error) []sentry.Exception { event := sentry.NewEvent() - event.SetException(err, 10) + // Match sentry-go's default client depth so tests see complete error chains. + event.SetException(err, 100) return event.Exception } diff --git a/pkg/errorsx/errorsxtest/errorsxtest_test.go b/pkg/errorsx/errorsxtest/errorsxtest_test.go new file mode 100644 index 000000000..918e4ea1e --- /dev/null +++ b/pkg/errorsx/errorsxtest/errorsxtest_test.go @@ -0,0 +1,33 @@ +// +// 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 errorsxtest + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrorExceptionsUsesSentryDefaultDepth(t *testing.T) { + err := errors.New("root") + for range 12 { + err = fmt.Errorf("wrapped: %w", err) + } + + assert.Len(t, ErrorExceptions(err), 13) +}