diff --git a/pkg/modules/webhook/async_context.go b/pkg/modules/webhook/async_context.go index 0dc355d..134a955 100644 --- a/pkg/modules/webhook/async_context.go +++ b/pkg/modules/webhook/async_context.go @@ -3,24 +3,38 @@ package webhook import ( "context" + "go.opentelemetry.io/otel/trace" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/api" ) // detachAsyncContext detaches ctx from the inbound request lifecycle so the // webhook goroutine survives echo recycling the request, while preserving the -// conversion deadline. +// conversion deadline and the caller's trace. // // Echo cancels the request context as soon as the synchronous handler returns // [api.ErrAsyncProcess], which would abort the asynchronous work. Detaching via // [context.WithoutCancel] severs that cancellation while keeping the context -// 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. +// values. [context.WithoutCancel] also drops the deadline, so it is re-layered. +// +// The server span ends as soon as that handler returns, so its span context is +// re-seated as a remote, non-recording parent: the asynchronous worker keeps +// the same trace without recording into a span that is about to end. A +// worker-root [webhook.Async] span is then opened, linked back to the +// originating request span, and stays open for the whole delivery so downstream +// conversion and webhook spans have a live parent in the caller's trace. +// +// The returned cancel function ends the worker span and cleans up both the +// detached context and the original working directory. func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.CancelFunc { deadline, hasDeadline := ctx.Deadline() + + serverSpanCtx := trace.SpanContextFromContext(ctx.Context) base := context.WithoutCancel(ctx.Context) + if serverSpanCtx.IsValid() { + base = trace.ContextWithRemoteSpanContext(base, serverSpanCtx) + } var detachedCtx context.Context var detachedCancel context.CancelFunc @@ -30,10 +44,18 @@ func detachAsyncContext(ctx *api.Context, cancel context.CancelFunc) context.Can // Fallback if no deadline was set (rare, as newContext enforces it). detachedCtx, detachedCancel = context.WithCancel(base) } - ctx.Context = detachedCtx + + startOpts := []trace.SpanStartOption{trace.WithSpanKind(trace.SpanKindInternal)} + if serverSpanCtx.IsValid() { + startOpts = append(startOpts, trace.WithLinks(trace.Link{SpanContext: serverSpanCtx})) + } + + workerCtx, workerSpan := gotenberg.Tracer().Start(detachedCtx, "webhook.Async", startOpts...) + ctx.Context = workerCtx originalCancel := cancel return func() { + workerSpan.End() detachedCancel() originalCancel() }