gqlgen's DefaultRecover prints the panic and debug.PrintStack() to stderr, so the log collector stores every stack line as a separate, level-less entry. Recover logs the panic as a single ERROR record via slog.Default() with panic, stack and path attributes, records it on the active span with status Error, and returns the generic "internal system error" without leaking the panic text. Install with srv.SetRecoverFunc(otelsetup.Recover). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H6eVuR4Yt3m7TbDTDwyZJi
118 lines
3.3 KiB
Go
118 lines
3.3 KiB
Go
package otelsetup
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/99designs/gqlgen/graphql"
|
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
|
"go.opentelemetry.io/otel/codes"
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
)
|
|
|
|
type recordingHandler struct {
|
|
mu sync.Mutex
|
|
records []slog.Record
|
|
}
|
|
|
|
func (h *recordingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
|
|
|
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.records = append(h.records, r.Clone())
|
|
return nil
|
|
}
|
|
|
|
func (h *recordingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
|
|
func (h *recordingHandler) WithGroup(string) slog.Handler { return h }
|
|
|
|
func TestRecover(t *testing.T) {
|
|
var _ graphql.RecoverFunc = Recover
|
|
|
|
tests := []struct {
|
|
name string
|
|
panicVal any
|
|
wantPanic string
|
|
}{
|
|
{name: "error value", panicVal: errors.New("runtime error: invalid memory address or nil pointer dereference"), wantPanic: "runtime error: invalid memory address or nil pointer dereference"},
|
|
{name: "string value", panicVal: "boom", wantPanic: "boom"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &recordingHandler{}
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(h))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
sr := tracetest.NewSpanRecorder()
|
|
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
|
|
ctx, span := tp.Tracer("test").Start(context.Background(), "resolver")
|
|
|
|
err := Recover(ctx, tt.panicVal)
|
|
span.End()
|
|
|
|
var gqlErr *gqlerror.Error
|
|
if !errors.As(err, &gqlErr) || gqlErr.Message != "internal system error" {
|
|
t.Fatalf("Recover() error = %v, want gqlerror \"internal system error\"", err)
|
|
}
|
|
if strings.Contains(err.Error(), tt.wantPanic) {
|
|
t.Errorf("Recover() error leaks panic text: %v", err)
|
|
}
|
|
|
|
if len(h.records) != 1 {
|
|
t.Fatalf("got %d log records, want exactly 1", len(h.records))
|
|
}
|
|
rec := h.records[0]
|
|
if rec.Level != slog.LevelError {
|
|
t.Errorf("log level = %v, want ERROR", rec.Level)
|
|
}
|
|
got := map[string]string{}
|
|
rec.Attrs(func(a slog.Attr) bool {
|
|
got[a.Key] = a.Value.String()
|
|
return true
|
|
})
|
|
if got["panic"] != tt.wantPanic {
|
|
t.Errorf("panic attr = %q, want %q", got["panic"], tt.wantPanic)
|
|
}
|
|
if !strings.Contains(got["stack"], "goroutine") {
|
|
t.Errorf("stack attr missing stack trace: %q", got["stack"])
|
|
}
|
|
|
|
spans := sr.Ended()
|
|
if len(spans) != 1 {
|
|
t.Fatalf("got %d spans, want 1", len(spans))
|
|
}
|
|
s := spans[0]
|
|
if s.Status().Code != codes.Error {
|
|
t.Errorf("span status = %v, want Error", s.Status().Code)
|
|
}
|
|
var exception bool
|
|
for _, ev := range s.Events() {
|
|
if ev.Name != "exception" {
|
|
continue
|
|
}
|
|
exception = true
|
|
attrs := map[string]string{}
|
|
for _, kv := range ev.Attributes {
|
|
attrs[string(kv.Key)] = kv.Value.String()
|
|
}
|
|
if attrs["exception.message"] != tt.wantPanic {
|
|
t.Errorf("exception.message = %q, want %q", attrs["exception.message"], tt.wantPanic)
|
|
}
|
|
if attrs["exception.stacktrace"] == "" {
|
|
t.Error("exception event has no stacktrace")
|
|
}
|
|
}
|
|
if !exception {
|
|
t.Error("span has no exception event")
|
|
}
|
|
})
|
|
}
|
|
}
|