Files
warmbly/internal/infrastructure/db/error.go
T

87 lines
2.8 KiB
Go

package db
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/jackc/pgx/v5/pgconn"
"github.com/warmbly/warmbly/internal/observability/errs"
)
func CaptureError(err error, query string, params []any, operation string) {
if err == nil {
return
}
// A cancelled context is the caller going away: a browser navigating off a
// slow list, a worker shutting down mid-pass, a request that hit its
// deadline and was already answered. The query did not fail, so reporting
// it filed the caller's own decision as a database incident and buried the
// real ones under it. The deadline case is the same event seen from the
// other side, and the pool reports an in-flight cancellation as a closed
// connection, which is why that is here too.
if isCallerGone(err) {
return
}
shape := paramShape(params)
wrappedErr := fmt.Errorf("%s failed: %w (query: %s, params: %s)", operation, err, query, shape)
opts := []errs.Option{
errs.Tag("db.operation", operation),
errs.Tag("db.query", query),
errs.Extra("db.params", shape),
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
opts = append(opts,
errs.Extra("pg.code", pgErr.Code), // e.g., "23505" for unique violation
errs.Extra("pg.detail", pgErr.Detail),
errs.Extra("pg.hint", pgErr.Hint),
)
}
errs.CaptureException(wrappedErr, opts...)
}
// isCallerGone reports whether an error only means the caller stopped waiting.
//
// pgx surfaces a cancellation three ways depending on where it landed: as the
// context error itself, as a Postgres 57014 (query_canceled) when the server
// had already started, and as "conn closed" / "failed to deallocate cached
// statement(s)" when the pool tore the connection down with the statement
// still on it. None of the three is a fault in the query.
func isCallerGone(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "57014" {
return true
}
return strings.Contains(err.Error(), "conn closed")
}
// paramShape describes the query's arguments without their values.
//
// The values are the row: email addresses, password hashes, tokens, message
// bodies. The query text next to them already says which column each one is,
// so reporting them would put the most sensitive data in the system into an
// error tracker. How many there were and of what type is enough to tell one
// call site from another, which is all the report is for.
func paramShape(params []any) string {
if len(params) == 0 {
return "none"
}
kinds := make([]string, 0, len(params))
for _, p := range params {
if p == nil {
kinds = append(kinds, "nil")
continue
}
kinds = append(kinds, fmt.Sprintf("%T", p))
}
return strconv.Itoa(len(params)) + " (" + strings.Join(kinds, ", ") + ")"
}