fix(webhook): preserve trace context across async detach via WithoutCancel

This commit is contained in:
Julien Neuhart
2026-06-02 18:51:25 +02:00
parent cb461bb1fe
commit b32545e589
2 changed files with 50 additions and 14 deletions

View File

@@ -11,22 +11,30 @@ import (
// conversion deadline. // conversion deadline.
// //
// Echo cancels the request context as soon as the synchronous handler returns // Echo cancels the request context as soon as the synchronous handler returns
// [api.ErrAsyncProcess], which would abort the asynchronous work. Replacing the // [api.ErrAsyncProcess], which would abort the asynchronous work. Detaching via
// embedded context severs that cancellation. The returned cancel function // [context.WithoutCancel] severs that cancellation while keeping the context
// cleans up both the detached context and the original working directory. // values, most importantly the active trace span, so downstream conversion and
// webhook spans stay in the caller's trace instead of starting a new one.
// [context.WithoutCancel] also drops the deadline, so it is re-layered. The
// returned cancel function cleans up both the detached context and the original
// working directory.
func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc { func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc {
if deadline, ok := ctx.Deadline(); ok { deadline, hasDeadline := ctx.Deadline()
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline) base := context.WithoutCancel(ctx.Context)
ctx.Context = detachedCtx
originalCancel := cancel var detachedCtx context.Context
return func() { var detachedCancel context.CancelFunc
detachedCancel() if hasDeadline {
originalCancel() detachedCtx, detachedCancel = context.WithDeadline(base, deadline)
} } else {
// Fallback if no deadline was set (rare, as newContext enforces it).
detachedCtx, detachedCancel = context.WithCancel(base)
} }
ctx.Context = detachedCtx
// Fallback if no deadline was set (rare, as newContext enforces it). originalCancel := cancel
ctx.Context = context.Background() return func() {
return cancel detachedCancel()
originalCancel()
}
} }

View File

@@ -5,9 +5,37 @@ import (
"testing" "testing"
"time" "time"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
) )
func TestDetachAsyncContext_PreservesTraceContext(t *testing.T) {
traceID, _ := trace.TraceIDFromHex("0123456789abcdef0123456789abcdef")
spanID, _ := trace.SpanIDFromHex("0123456789abcdef")
sc := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
TraceFlags: trace.FlagsSampled,
Remote: true,
})
reqCtx, reqCancel := context.WithDeadline(
trace.ContextWithSpanContext(context.Background(), sc),
time.Now().Add(2*time.Hour),
)
defer reqCancel()
ctx := &api.Context{Context: reqCtx}
cancel := detachAsyncContext(ctx, func() {})
defer cancel()
got := trace.SpanContextFromContext(ctx.Context)
if got.TraceID() != sc.TraceID() {
t.Errorf("expected the detached context to keep trace id %s, got %s", sc.TraceID(), got.TraceID())
}
}
func TestDetachAsyncContext_PreservesDeadline(t *testing.T) { func TestDetachAsyncContext_PreservesDeadline(t *testing.T) {
deadline := time.Now().Add(2 * time.Hour) deadline := time.Now().Add(2 * time.Hour)
reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline) reqCtx, reqCancel := context.WithDeadline(context.Background(), deadline)