otelsetup / test (push) Successful in 43s
otelsetup / vulnerabilities (push) Successful in 44s
Unbound Release / Check Preconditions (push) Successful in 1s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Create Release (push) Successful in 4s
Unbound Release / Generate Changelog and Handle PR (push) Successful in 9s
Release / release (push) Successful in 14s
pre-commit / pre-commit (push) Successful in 2m25s
## Summary gqlgen's `graphql.DefaultRecover` prints a recovered resolver panic and `debug.PrintStack()` to stderr, so the log collector stores every stack line as a separate, level-less entry (~60 entries per panic in production). Adds `otelsetup.Recover` (`func(ctx context.Context, err any) error`, a `graphql.RecoverFunc` for gqlgen v0.17.95): - logs the panic as **one** ERROR record via `slog.Default().ErrorContext` (the default logger services set up through `logging.SetupLogger`), with `panic`, `stack` (`debug.Stack()`) and `path` (GraphQL field path, when available) attributes - records the error on the active span (`RecordError` with stack trace, `SetStatus(codes.Error)`) - returns the generic `internal system error` gqlerror to the client, without the panic text Services install it with `srv.SetRecoverFunc(otelsetup.Recover)`. ## Test plan - `TestRecover`: error and non-error panic values; asserts the generic gqlerror, no panic text leak, exactly one ERROR log record with panic/stack attributes, and span status Error with an `exception` event carrying message and stacktrace (in-memory `tracetest.SpanRecorder`) - `go test -race ./...` passes, `prek run --all-files` passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01H6eVuR4Yt3m7TbDTDwyZJi Reviewed-on: #206
42 lines
1.3 KiB
Go
42 lines
1.3 KiB
Go
package otelsetup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"runtime/debug"
|
|
|
|
"github.com/99designs/gqlgen/graphql"
|
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
// Recover is a gqlgen recover function, installed with srv.SetRecoverFunc(otelsetup.Recover).
|
|
// Unlike graphql.DefaultRecover, which prints the stack to stderr line by line, it logs the
|
|
// panic as a single structured ERROR record (via slog.Default) with the stack as an attribute,
|
|
// records it on the active span and marks the span as failed. The client gets the same generic
|
|
// "internal system error" as with the default, without the panic text.
|
|
func Recover(ctx context.Context, err any) error {
|
|
stack := string(debug.Stack())
|
|
panicErr, ok := err.(error)
|
|
if !ok {
|
|
panicErr = fmt.Errorf("%v", err)
|
|
}
|
|
|
|
attrs := []any{
|
|
slog.String("panic", panicErr.Error()),
|
|
slog.String("stack", stack),
|
|
}
|
|
if fc := graphql.GetFieldContext(ctx); fc != nil {
|
|
attrs = append(attrs, slog.String("path", fc.Path().String()))
|
|
}
|
|
slog.Default().ErrorContext(ctx, "recovered panic in graphql resolver", attrs...)
|
|
|
|
span := trace.SpanFromContext(ctx)
|
|
span.RecordError(panicErr, trace.WithStackTrace(true))
|
|
span.SetStatus(codes.Error, "panic: "+panicErr.Error())
|
|
|
|
return gqlerror.Errorf("internal system error")
|
|
}
|