Repository: JasperFx/wolverine (2026-07-23T18:07:06Z)

Issue:
Deserialization failures mark spans as errors without recording exception details
## Description

When inbound envelope deserialization fails, `HandlerPipeline.TryDeserializeEnvelope()` catches the exception and marks the optional `wolverine.deserialize` activity as failed, but does not record the exception on that activity. The parent message-processing activity is then marked with the generic status description `Serialization Failure`.

As a result, OTLP exporters receive an error span without the exception type, message, or stack trace needed to diagnose the malformed or incompatible payload.

## Affected version

Confirmed on WolverineFx `6.16.0`. The same behavior is currently present on `main`.

## Current behavior

`TryDeserializeEnvelope()` only sets status before moving the envelope to the error queue:

```csharp
catch (Exception? e)
{
    activity?.SetStatus(ActivityStatusCode.Error, e.GetType().Name);
    return new MoveToErrorQueue(e);
}
```

The caller then replaces the useful failure description on the parent activity with a generic value:

```csharp
activity?.SetStatus(ActivityStatusCode.Error, "Serialization Failure");
```

Source (`V6.16.0`):
https://github.com/JasperFx/wolverine/blob/feba5cd21c4cefa019689144812883dea42c9c4e/src/Wolverine/Runtime/HandlerPipeline.cs#L175-L245

## Expected behavior

When `WolverineOptions.Tracking.DeserializationSpanEnabled` is enabled, the deserialization activity should record the caught exception using standard .NET OpenTelemetry conventions, for example:

```csharp
activity?.SetStatus(ActivityStatusCode.Error, e.GetType().Name);
activity?.AddException(e);
```

This should produce an `exception` activity event containing `exception.type`, `exception.message`, and `exception.stacktrace`, while preserving the existing opt-in behavior and avoiding extra spans when deserialization diagnostics are disabled.

## Suggested verification

Add a test that enables `DeserializationSpanEnabled`, forces serializer deserialization to throw, and verifies that the exported `wolverine.deserialize` activity:

- has `ActivityStatusCode.Error`;
- contains one `exception` event;
- includes exception type, message, and stack trace attributes;
- still moves the failed envelope through the existing error-queue path.



Discussion:

Patch:
diff --git a/src/Wolverine/Runtime/HandlerPipeline.cs b/src/Wolverine/Runtime/HandlerPipeline.cs
index d49ec7bae..eb1bb3d0b 100644
--- a/src/Wolverine/Runtime/HandlerPipeline.cs
+++ b/src/Wolverine/Runtime/HandlerPipeline.cs
@@ -242,6 +242,15 @@ public async ValueTask<IContinuation> TryDeserializeEnvelope(Envelope envelope)
         catch (Exception? e)
         {
             activity?.SetStatus(ActivityStatusCode.Error, e.GetType().Name);
+
+            // Record the caught exception as an OpenTelemetry "exception" event so
+            // OTLP exporters see exception.type / exception.message / exception.stacktrace
+            // for the malformed or incompatible payload, instead of only the generic
+            // "Serialization Failure" status the parent activity is later stamped with.
+            // AddException is a no-op when activity is null (span not started because
+            // DeserializationSpanEnabled is off), so the opt-in cost stays trivial.
+            activity?.AddException(e);
+
             return new MoveToErrorQueue(e);
         }
         finally


Test patch:
diff --git a/src/Testing/CoreTests/Runtime/tracking_diagnostics_opt_in.cs b/src/Testing/CoreTests/Runtime/tracking_diagnostics_opt_in.cs
index 1ea51d1f9..a25710e22 100644
--- a/src/Testing/CoreTests/Runtime/tracking_diagnostics_opt_in.cs
+++ b/src/Testing/CoreTests/Runtime/tracking_diagnostics_opt_in.cs
@@ -2,9 +2,12 @@
 using JasperFx.Core;
 using Microsoft.Extensions.Hosting;
 using Shouldly;
+using Wolverine;
+using Wolverine.ErrorHandling;
 using Wolverine.Runtime;
 using Wolverine.Runtime.Handlers;
 using Wolverine.Tracking;
+using Wolverine.Util;
 using Xunit;
 using Xunit.Abstractions;
 
@@ -347,6 +350,61 @@ public async Task deserialize_span_does_not_start_when_disabled()
         }
     }
 
+    [Fact]
+    public async Task deserialize_span_records_exception_detail_on_failure_when_enabled()
+    {
+        var (host, captured, listener) = await startWithTrackingAsync(t =>
+            t.DeserializationSpanEnabled = true);
+
+        try
+        {
+            // TrackingDiagnosticsHandler makes TrackingDiagnosticsMessage a known
+            // message type, so TryDeserializeEnvelope gets past the type lookup and
+            // hands the malformed body to the serializer, which throws. Building the
+            // pipeline directly is the same seam FaultCryptoExceptionGuardTests uses;
+            // the serialized-inbound path can't be synthesized through InvokeMessageAndWait.
+            var runtime = host.GetRuntime();
+            var pipeline = new HandlerPipeline(runtime, runtime);
+
+            var envelope = new Envelope
+            {
+                ContentType = "application/json",
+                MessageType = typeof(TrackingDiagnosticsMessage).ToMessageTypeName(),
+                // Truncated JSON — the System.Text.Json reader throws a JsonException.
+                Data = "{ \"Text\": "u8.ToArray()
+            };
+
+            var continuation = await pipeline.TryDeserializeEnvelope(envelope);
+
+            // Existing error-queue path is preserved.
+            var moveToErrorQueue = continuation.ShouldBeOfType<MoveToErrorQueue>();
+            moveToErrorQueue.Exception.ShouldNotBeNull();
+            envelope.Message.ShouldBeNull();
+
+            var deserializeActivity = captured.Single(a => a.OperationName == WolverineTracing.Deserialize);
+            deserializeActivity.Status.ShouldBe(ActivityStatusCode.Error);
+
+            // AddException emits exactly one "exception" event carrying the standard
+            // OpenTelemetry exception.* attributes, so OTLP exporters can diagnose the
+            // malformed payload instead of only seeing the generic "Serialization Failure".
+            var exceptionEvents = deserializeActivity.Events
+                .Where(e => e.Name == "exception")
+                .ToArray();
+            exceptionEvents.Length.ShouldBe(1);
+
+            var tags = exceptionEvents[0].Tags.ToDictionary(t => t.Key, t => t.Value);
+            tags["exception.type"].ShouldBe(moveToErrorQueue.Exception.GetType().FullName);
+            tags["exception.message"].ShouldBe(moveToErrorQueue.Exception.Message);
+            tags.Keys.ShouldContain("exception.stacktrace");
+        }
+        finally
+        {
+            listener.Dispose();
+            await host.StopAsync();
+            host.Dispose();
+        }
+    }
+
     #endregion
 
     #region OutboxDiagnosticsEnabled