Skip to content

Restore the ambient activity after a recoverability span starts a new trace - #7969

Open
tmasternak wants to merge 4 commits into
otel-legacy-status-okfrom
otel-restore-ambient-activity
Open

tmasternak wants to merge 4 commits into
otel-legacy-status-okfrom
otel-restore-ambient-activity

Conversation

@tmasternak

Copy link
Copy Markdown
Member

Stacked on #7965 (base branch otel-legacy-status-ok).

RecoverabilityPipelineExecutor disposes the recoverability activity before invoking the recoverability pipeline. When that activity started a new trace, the recoverability pipeline — delayed retries and moves to the error queue — ran with Activity.Current == null instead of the ambient activity, for example the transport's receive span.

Why

Activity.Stop() makes the activity that was current when Start() ran current again (Activity._previousActiveActivity), not Activity.Parent. To start a new trace, ActivityFactory.StartActivityFromIncomingMessage sets Activity.Current = null before Start() so the new span becomes a root — see the runtime discussion referenced in the code. That also makes the saved "previous" activity null, so after Stop() nothing brings the ambient span back. The restore cannot happen inside the factory before Start(), because Start() would then adopt the ambient activity as the parent and the trace would not be new.

The ContinueExisting path is unaffected: its activity has a remote parent but Activity.Current was not cleared, so Stop() restores the ambient activity on its own. MainPipelineExecutor is also unaffected: its activity is disposed as the last statement of Invoke, and AsyncLocal changes do not flow back to the awaiting transport pump.

Changes

  • ActivityExtensions.RestoreAmbientActivity(Activity?): sets Activity.Current back, skipping a stopped activity, which the runtime would reject.
  • RecoverabilityPipelineExecutor: captures Activity.Current before StartRecoverabilityActivity and restores it in a finally once the activity is disposed, so the restore also happens when the policy or the logger throws.
  • RecoverabilityExecutorTests.Should_run_recoverability_pipeline_under_the_ambient_activity: starts an ambient activity, invokes the executor with traceparent and NServiceBus.OpenTelemetry.StartNewTrace headers, and asserts the recoverability pipeline observes the ambient activity as Activity.Current. Verified to fail without the fix.

Testing

  • NServiceBus.Core.Tests pass locally except the NativeAOT/packaging tests, which need a Release analyzer build not present in my checkout.

The recoverability activity ended as soon as the recoverability policy
had decided, so its duration covered the decision and not the retry
dispatch or the move to the error queue that followed.

The activity now wraps the whole executor: the decision, the tag that
records it and the recoverability pipeline. A failure of that pipeline is
recorded on the activity before the exception propagates.
…d from

The incoming and handler activities record the failure, then the
recoverability activity starts from the message headers alone, so a trace
shows no relation between the failed processing and what recoverability
did about it.

RecordError knows the innermost activity an exception escaped from: the
first one to record it. When it is handed the pipeline context, it writes
that activity's context to the root context bag, the one the transport
hands to OnMessage and floats to OnError as ErrorContext.Extensions. The
recoverability activity takes it from there and adds it as a link. An
outgoing activity that fails inside a handler records without a bag and
is skipped, so the link always points at a pipeline activity.

Nothing is linked when no processing activity was created, when the
failure happened outside the activities or when the transport does not
float the bag. The context is consumed on read so a reused bag cannot
carry it into the next attempt.
In Logs mode ActivityFactory.RecordError logged the exception from the
innermost activity it escaped from, at Error level regardless of what
recoverability was going to do, and the recoverability log entry carried
the exception only in SpanAndLogs mode. A retried message produced an
Error log entry for a failure that was handled.

For failures in the message processing pipeline the exception is now
logged once, when recoverability has decided, with the exception attached
whatever the ExceptionRecordingMode: Information for an immediate retry
or a discard, Warning for a delayed retry, Error for a move to the error
queue. The entry is written while the recoverability activity is current,
which links to the activity the exception escaped from.

Failures outside that pipeline have no recoverability to log them: a
failed operation on the message session and a failure of the
recoverability pipeline itself are logged right away, at Error level,
while their activity is current. RecordError tells the two apart by
whether it is handed a context bag.

ExceptionRecordingMode only controls whether the exception is also
recorded as an event on the activity it escaped from.
@tmasternak
tmasternak force-pushed the otel-restore-ambient-activity branch from 71c5304 to e587eec Compare October 16, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant