Repository: Particular/ServiceControl
Pull request #4943: Refine test to show minimal metadata needed for errors to be imported successfully
Author: login: andreasohlund
Created: 2025-04-22T10:37:51Z | Merged: 2025-04-25T08:41:34Z

I did some testing to figure out which minimal set of metadata is needed for error imports to work.

Notes:

- Requiring the `ProcessingMachine` feels off, I think we should default it to null or unknown to be more flexible when importing
- MessageId header is needed for some reason, even though we have the native message id from the transport

I will raise follow-up PRs to address the above


Diff:
diff --git a/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs b/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs
new file mode 100644
index 0000000000..d79b88333c
--- /dev/null
+++ b/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs
@@ -0,0 +1,132 @@
+﻿namespace ServiceControl.AcceptanceTests.Recoverability.MessageFailures;
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.EndpointTemplates;
+using Infrastructure;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.Faults;
+using NServiceBus.Routing;
+using NServiceBus.Transport;
+using NUnit.Framework;
+using ServiceControl.MessageFailures.Api;
+
+class When_ingesting_failed_message_with_missing_headers : AcceptanceTest
+{
+    [Test]
+    public async Task Should_be_ingested_when_minimal_required_headers_is_present()
+    {
+        var testStartTime = DateTime.UtcNow;
+
+        var context = await Define<TestContext>(c => c.AddMinimalRequiredHeaders())
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.Null);
+
+        //No failure time will result in utc now being used
+        Assert.That(failure.TimeOfFailure, Is.GreaterThan(testStartTime));
+
+        // Both host and endpoint name is currently needed so this will be null since no host can be detected from the failed q header
+        Assert.That(failure.ReceivingEndpoint, Is.Null);
+    }
+
+    [Test]
+    public async Task Should_include_headers_required_by_ServicePulse()
+    {
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+
+                // This is needed for ServiceControl to be able to detect both endpoint (via failed q header) and host via the processing machine header
+                // Missing endpoint or host will cause a null ref in ServicePulse
+                c.Headers[Headers.ProcessingMachine] = "MyMachine";
+
+                c.Headers[FaultsHeaderKeys.ExceptionType] = "SomeExceptionType";
+                c.Headers[FaultsHeaderKeys.Message] = "Some message";
+            })
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+
+        // ServicePulse assumes that the receiving endpoint name is present
+        Assert.That(failure.ReceivingEndpoint, Is.Not.Null);
+        Assert.That(failure.ReceivingEndpoint.Name, Is.EqualTo(context.EndpointNameOfReceivingEndpoint));
+        Assert.That(failure.ReceivingEndpoint.Host, Is.EqualTo("MyMachine"));
+
+        // ServicePulse needs both an exception type and description to render the UI in a resonable way
+        Assert.That(failure.Exception.ExceptionType, Is.EqualTo("SomeExceptionType"));
+        Assert.That(failure.Exception.Message, Is.EqualTo("Some message"));
+    }
+
+    [Test]
+    public async Task TimeSent_should_not_be_casted()
+    {
+        var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z");
+
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+                c.Headers.Add(Headers.TimeSent, DateTimeOffsetHelper.ToWireFormattedString(sentTime));
+            })
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.EqualTo(sentTime));
+    }
+
+    async Task<bool> TryGetFailureFromApi(TestContext context)
+    {
+        context.Failure = await this.TryGet<FailedMessageView>($"/api/errors/last/{context.UniqueMessageId}");
+        return context.Failure != null;
+    }
+
+    class TestContext : ScenarioContext
+    {
+        public string MessageId { get; } = Guid.NewGuid().ToString();
+
+        public string EndpointNameOfReceivingEndpoint => "MyEndpoint";
+
+        public string UniqueMessageId => DeterministicGuid.MakeId(MessageId, EndpointNameOfReceivingEndpoint).ToString();
+
+        public Dictionary<string, string> Headers { get; } = [];
+
+        public FailedMessageView Failure { get; set; }
+
+        public void AddMinimalRequiredHeaders()
+        {
+            Headers[FaultsHeaderKeys.FailedQ] = EndpointNameOfReceivingEndpoint;
+            Headers[NServiceBus.Headers.MessageId] = MessageId;
+        }
+    }
+
+    class FailingEndpoint : EndpointConfigurationBuilder
+    {
+        public FailingEndpoint() => EndpointSetup<DefaultServerWithoutAudit>();
+
+        class SendFailedMessage : DispatchRawMessages<TestContext>
+        {
+            protected override TransportOperations CreateMessage(TestContext context)
+            {
+                var outgoingMessage = new OutgoingMessage(context.MessageId, context.Headers, Array.Empty<byte>());
+
+                return new TransportOperations(new TransportOperation(outgoingMessage, new UnicastAddressTag("error")));
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs b/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
deleted file mode 100644
index 6fad5fd287..0000000000
--- a/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
+++ /dev/null
@@ -1,126 +0,0 @@
-﻿namespace ServiceControl.AcceptanceTests.Recoverability.MessageFailures
-{
-    using System;
-    using System.Collections.Generic;
-    using System.Threading.Tasks;
-    using AcceptanceTesting;
-    using AcceptanceTesting.EndpointTemplates;
-    using Infrastructure;
-    using NServiceBus;
-    using NServiceBus.AcceptanceTesting;
-    using NServiceBus.Routing;
-    using NServiceBus.Transport;
-    using NUnit.Framework;
-    using ServiceControl.MessageFailures.Api;
-    using Conventions = NServiceBus.AcceptanceTesting.Customization.Conventions;
-
-    class When_processing_message_with_missing_metadata_failed : AcceptanceTest
-    {
-        [Test]
-        public async Task Null_TimeSent_should_not_be_cast_to_DateTimeMin()
-        {
-            FailedMessageView failure = null;
-
-            await Define<MyContext>()
-                .WithEndpoint<Failing>()
-                .Done(async c =>
-                {
-                    var result = await this.TryGetSingle<FailedMessageView>("/api/errors/", m => m.Id == c.UniqueMessageId);
-                    failure = result;
-                    return result;
-                })
-                .Run();
-
-            Assert.That(failure, Is.Not.Null);
-            Assert.That(failure.TimeSent, Is.Null);
-        }
-
-        [Test]
-        public async Task TimeSent_should_not_be_casted()
-        {
-            FailedMessageView failure = null;
-
-            var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z");
-
-            await Define<MyContext>(ctx => { ctx.TimeSent = sentTime; })
-                .WithEndpoint<Failing>()
-                .Done(async c =>
-                {
-                    var result = await this.TryGet<FailedMessageView>($"/api/errors/last/{c.UniqueMessageId}");
-                    failure = result;
-                    return c.UniqueMessageId != null & result;
-                })
-                .Run();
-
-            Assert.That(failure, Is.Not.Null);
-            Assert.That(failure.TimeSent, Is.EqualTo(sentTime));
-        }
-
-        [Test]
-        public async Task Should_be_able_to_get_the_message_by_id()
-        {
-            FailedMessageView failure = null;
-
-            await Define<MyContext>()
-                .WithEndpoint<Failing>()
-                .Done(async c =>
-                {
-                    var result = await this.TryGet<FailedMessageView>($"/api/errors/last/{c.UniqueMessageId}");
-                    failure = result;
-                    return c.UniqueMessageId != null & result;
-                })
-                .Run();
-
-            Assert.That(failure, Is.Not.Null);
-        }
-
-        public class Failing : EndpointConfigurationBuilder
-        {
-            public Failing() => EndpointSetup<DefaultServerWithoutAudit>(c => { c.Recoverability().Delayed(x => x.NumberOfRetries(0)); });
-
-            class SendFailedMessage : DispatchRawMessages<MyContext>
-            {
-                protected override TransportOperations CreateMessage(MyContext context)
-                {
-                    context.EndpointNameOfReceivingEndpoint = Conventions.EndpointNamingConvention(typeof(Failing));
-                    context.MessageId = Guid.NewGuid().ToString();
-                    context.UniqueMessageId = DeterministicGuid.MakeId(context.MessageId, context.EndpointNameOfReceivingEndpoint).ToString();
-
-                    var headers = new Dictionary<string, string>
-                    {
-                        [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
-                        ["NServiceBus.ExceptionInfo.ExceptionType"] = "2014-11-11 02:26:57:767462 Z",
-                        ["NServiceBus.ExceptionInfo.Message"] = "An error occurred while attempting to extract logical messages from transport message NServiceBus.TransportMessage",
-                        ["NServiceBus.ExceptionInfo.InnerExceptionType"] = "System.Exception",
-                        ["NServiceBus.ExceptionInfo.Source"] = "NServiceBus.Core",
-                        ["NServiceBus.ExceptionInfo.StackTrace"] = string.Empty,
-                        ["NServiceBus.FailedQ"] = Conventions.EndpointNamingConvention(typeof(Failing)),
-                        ["NServiceBus.TimeOfFailure"] = "2014-11-11 02:26:58:000462 Z"
-                    };
-                    if (context.TimeSent.HasValue)
-                    {
-                        headers["NServiceBus.TimeSent"] = DateTimeOffsetHelper.ToWireFormattedString(context.TimeSent.Value);
-                    }
-
-                    var outgoingMessage = new OutgoingMessage(context.MessageId, headers, new byte[0]);
-
-                    return new TransportOperations(
-                        new TransportOperation(outgoingMessage, new UnicastAddressTag("error"))
-                    );
-                }
-            }
-        }
-
-        public class MyContext : ScenarioContext
-        {
-            public string MessageId { get; set; }
-
-            public string EndpointNameOfReceivingEndpoint { get; set; }
-
-            public string UniqueMessageId { get; set; }
-
-            public DateTime? TimeSent { get; set; }
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/ServiceControl.Audit/Recoverability/DetectSuccessfulRetriesEnricher.cs b/src/ServiceControl.Audit/Recoverability/DetectSuccessfulRetriesEnricher.cs
index d3f17194c5..618c36c765 100644
--- a/src/ServiceControl.Audit/Recoverability/DetectSuccessfulRetriesEnricher.cs
+++ b/src/ServiceControl.Audit/Recoverability/DetectSuccessfulRetriesEnricher.cs
@@ -7,6 +7,7 @@
     using Contracts.MessageFailures;
     using Infrastructure;
     using NServiceBus;
+    using NServiceBus.Faults;
     using NServiceBus.Routing;
     using NServiceBus.Transport;
     using ServiceControl.Audit.Persistence.Infrastructure;
@@ -64,7 +65,7 @@ IEnumerable<string> GetAlternativeUniqueMessageId(IReadOnlyDictionary<string, st
                 yield return DeterministicGuid.MakeId(messageId, processingEndpoint).ToString();
             }
 
-            if (headers.TryGetValue("NServiceBus.FailedQ", out var failedQ))
+            if (headers.TryGetValue(FaultsHeaderKeys.FailedQ, out var failedQ))
             {
                 yield return DeterministicGuid.MakeId(messageId, ExtractQueueNameForLegacyReasons(failedQ)).ToString();
             }
diff --git a/src/ServiceControl/Operations/EndpointDetailsParser.cs b/src/ServiceControl/Operations/EndpointDetailsParser.cs
index 557295e478..c2edcef186 100644
--- a/src/ServiceControl/Operations/EndpointDetailsParser.cs
+++ b/src/ServiceControl/Operations/EndpointDetailsParser.cs
@@ -4,6 +4,7 @@ namespace ServiceControl.Contracts.Operations
     using System.Collections.Generic;
     using Infrastructure;
     using NServiceBus;
+    using NServiceBus.Faults;
     using ServiceControl.Operations;
 
     class EndpointDetailsParser
@@ -13,7 +14,7 @@ public static EndpointDetails SendingEndpoint(IReadOnlyDictionary<string, string
             var endpointDetails = new EndpointDetails();
 
             DictionaryExtensions.CheckIfKeyExists(Headers.OriginatingEndpoint, headers, s => endpointDetails.Name = s);
-            DictionaryExtensions.CheckIfKeyExists("NServiceBus.OriginatingMachine", headers, s => endpointDetails.Host = s);
+            DictionaryExtensions.CheckIfKeyExists(Headers.OriginatingMachine, headers, s => endpointDetails.Host = s);
             DictionaryExtensions.CheckIfKeyExists(Headers.OriginatingHostId, headers, s => endpointDetails.HostId = Guid.Parse(s));
 
             if (!string.IsNullOrEmpty(endpointDetails.Name) && !string.IsNullOrEmpty(endpointDetails.Host))
@@ -50,7 +51,7 @@ public static EndpointDetails ReceivingEndpoint(IReadOnlyDictionary<string, stri
             }
             else
             {
-                DictionaryExtensions.CheckIfKeyExists("NServiceBus.ProcessingMachine", headers, s => endpoint.Host = s);
+                DictionaryExtensions.CheckIfKeyExists(Headers.ProcessingMachine, headers, s => endpoint.Host = s);
             }
 
             DictionaryExtensions.CheckIfKeyExists(Headers.ProcessingEndpoint, headers, s => endpoint.Name = s);
@@ -62,7 +63,7 @@ public static EndpointDetails ReceivingEndpoint(IReadOnlyDictionary<string, stri
 
             string address = null;
             //use the failed q to determine the receiving endpoint
-            DictionaryExtensions.CheckIfKeyExists("NServiceBus.FailedQ", headers, s => address = s);
+            DictionaryExtensions.CheckIfKeyExists(FaultsHeaderKeys.FailedQ, headers, s => address = s);
 
             // If we have a failed queue, then construct an endpoint from the failed queue information
             if (address != null)


Reviews:
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T12:36:45Z
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T12:38:39Z
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T12:39:15Z
user: login: andreasohlund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:42:59Z
user: login: andreasohlund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T07:48:43Z
user: login: andreasohlund
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T08:12:03Z
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T08:10:46Z
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T08:11:35Z
user: login: ramonsmits
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T08:12:28Z
user: login: ramonsmits
body: LGTM except for the string values
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T08:13:52Z

Review comments:
diff_hunk: @@ -89,30 +98,23 @@ protected override TransportOperations CreateMessage(MyContext context)
                     var headers = new Dictionary<string, string>
                     {
                         [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
-                        ["NServiceBus.ExceptionInfo.ExceptionType"] = "2014-11-11 02:26:57:767462 Z",
-                        ["NServiceBus.ExceptionInfo.Message"] = "An error occurred while attempting to extract logical messages from transport message NServiceBus.TransportMessage",
-                        ["NServiceBus.ExceptionInfo.InnerExceptionType"] = "System.Exception",
-                        ["NServiceBus.ExceptionInfo.Source"] = "NServiceBus.Core",
-                        ["NServiceBus.ExceptionInfo.StackTrace"] = string.Empty,
                         ["NServiceBus.FailedQ"] = Conventions.EndpointNamingConvention(typeof(Failing)),
-                        ["NServiceBus.TimeOfFailure"] = "2014-11-11 02:26:58:000462 Z"
+                        [Headers.ProcessingMachine] = "unknown", // This is needed for endpoint detection to work since "host" is required, endpoint name is detected from the FailedQ header
                     };
+
                     if (context.TimeSent.HasValue)
                     {
                         headers["NServiceBus.TimeSent"] = DateTimeOffsetHelper.ToWireFormattedString(context.TimeSent.Value);
                     }
 
-                    var outgoingMessage = new OutgoingMessage(context.MessageId, headers, new byte[0]);
+                    var outgoingMessage = new OutgoingMessage(context.MessageId, headers, Array.Empty<byte>());
 
-                    return new TransportOperations(
-                        new TransportOperation(outgoingMessage, new UnicastAddressTag("error"))
-                    );
+                    return new TransportOperations(new TransportOperation(outgoingMessage, new UnicastAddressTag("error")));
                 }
             }
         }
 
-        public class MyContext : ScenarioContext
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
user: login: ramonsmits
body: I see several changes in this diff which are unrelated to the actual change. IMHO it woul be better to do these in a separate PR.
created_at: 2025-04-22T12:36:45Z
author_association: MEMBER
diff_hunk: @@ -89,30 +98,23 @@ protected override TransportOperations CreateMessage(MyContext context)
                     var headers = new Dictionary<string, string>
                     {
                         [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
user: login: ramonsmits
body: Not having any error info should IMHO not be allowed. It was DLQ'd for a reason. It could be an processing error (exception) or another reason in the infrastructure..

THe SP UI is pretty useless for failures without errors as the grouping options would not work.

But for this test to pass it is indeed not required.
created_at: 2025-04-22T12:38:38Z
author_association: MEMBER
diff_hunk: @@ -37,13 +37,7 @@ public ErrorIngestor(Metrics metrics,
             bulkInsertDurationMeter = metrics.GetMeter("Error ingestion - bulk insert duration", FrequencyInMilliseconds);
             var ingestedMeter = metrics.GetCounter("Error ingestion - ingested");
 
-            var enrichers = new IEnrichImportedErrorMessages[]
-            {
-                new MessageTypeEnricher(),
-                new EnrichWithTrackingIds(),
-                new ProcessingStatisticsEnricher()
-
-            }.Concat(errorEnrichers).ToArray();
+            var enrichers = new IEnrichImportedErrorMessages[] { new MessageTypeEnricher(), new EnrichWithTrackingIds(), new ProcessingStatisticsEnricher() }.Concat(errorEnrichers).ToArray();
path: src/ServiceControl/Operations/ErrorIngestor.cs
user: login: ramonsmits
body: Is this really better? :-)
created_at: 2025-04-22T12:39:15Z
author_association: MEMBER
diff_hunk: @@ -89,30 +98,23 @@ protected override TransportOperations CreateMessage(MyContext context)
                     var headers = new Dictionary<string, string>
                     {
                         [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
user: login: andreasohlund
body: Agreed, at least a message should be provided. Since the UI becomes useless without it, perhaps we should add it here?
created_at: 2025-04-23T07:42:59Z
author_association: MEMBER
diff_hunk: @@ -89,30 +98,23 @@ protected override TransportOperations CreateMessage(MyContext context)
                     var headers = new Dictionary<string, string>
                     {
                         [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
-                        ["NServiceBus.ExceptionInfo.ExceptionType"] = "2014-11-11 02:26:57:767462 Z",
-                        ["NServiceBus.ExceptionInfo.Message"] = "An error occurred while attempting to extract logical messages from transport message NServiceBus.TransportMessage",
-                        ["NServiceBus.ExceptionInfo.InnerExceptionType"] = "System.Exception",
-                        ["NServiceBus.ExceptionInfo.Source"] = "NServiceBus.Core",
-                        ["NServiceBus.ExceptionInfo.StackTrace"] = string.Empty,
                         ["NServiceBus.FailedQ"] = Conventions.EndpointNamingConvention(typeof(Failing)),
-                        ["NServiceBus.TimeOfFailure"] = "2014-11-11 02:26:58:000462 Z"
+                        [Headers.ProcessingMachine] = "unknown", // This is needed for endpoint detection to work since "host" is required, endpoint name is detected from the FailedQ header
                     };
+
                     if (context.TimeSent.HasValue)
                     {
                         headers["NServiceBus.TimeSent"] = DateTimeOffsetHelper.ToWireFormattedString(context.TimeSent.Value);
                     }
 
-                    var outgoingMessage = new OutgoingMessage(context.MessageId, headers, new byte[0]);
+                    var outgoingMessage = new OutgoingMessage(context.MessageId, headers, Array.Empty<byte>());
 
-                    return new TransportOperations(
-                        new TransportOperation(outgoingMessage, new UnicastAddressTag("error"))
-                    );
+                    return new TransportOperations(new TransportOperation(outgoingMessage, new UnicastAddressTag("error")));
                 }
             }
         }
 
-        public class MyContext : ScenarioContext
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
user: login: andreasohlund
body: I've rolled back the other cleanup, fixes to this file feels close enough to be included
created_at: 2025-04-23T07:48:43Z
author_association: MEMBER
diff_hunk: @@ -89,30 +98,23 @@ protected override TransportOperations CreateMessage(MyContext context)
                     var headers = new Dictionary<string, string>
                     {
                         [Headers.MessageId] = context.MessageId,
-                        [Headers.ProcessingEndpoint] = context.EndpointNameOfReceivingEndpoint,
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_processing_message_with_missing_metadata_failed.cs
user: login: andreasohlund
body: When ingesting the DLQ messages from ASB the only 2 things available is DLQ Reason and DLQ Description, I chose to map those like this: (https://github.com/andreasohlund/AzureServiceBusDLQ?tab=readme-ov-file#nservicebus)

NServiceBus.ExceptionInfo.ExceptionType: Dead letter reason
NServiceBus.ExceptionInfo.Message: Dead letter description


This leads to a bit of a funky UI experience since SP assumes a stack trace to be available and prints a `0` instead when displaying the grouping 
![image](https://github.com/user-attachments/assets/3de4fefc-4d71-49cb-8a39-9976ecd6501f)

When showing the actual message it looks better

![image](https://github.com/user-attachments/assets/3865ec29-12b0-4839-8096-0975b882528e)



created_at: 2025-04-23T08:12:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,131 @@
+﻿namespace ServiceControl.AcceptanceTests.Recoverability.MessageFailures;
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.EndpointTemplates;
+using Infrastructure;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.Routing;
+using NServiceBus.Transport;
+using NUnit.Framework;
+using ServiceControl.MessageFailures.Api;
+
+class When_ingesting_failed_message_with_missing_headers : AcceptanceTest
+{
+    [Test]
+    public async Task Should_be_ingested_when_minimal_required_headers_is_present()
+    {
+        var testStartTime = DateTime.UtcNow;
+
+        var context = await Define<TestContext>(c => c.AddMinimalRequiredHeaders())
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.Null);
+
+        //No failure time will result in utc now being used
+        Assert.That(failure.TimeOfFailure, Is.GreaterThan(testStartTime));
+
+        // Both host and endpoint name is currently needed so this will be null since no host can be detected from the failed q header
+        Assert.That(failure.ReceivingEndpoint, Is.Null);
+    }
+
+    [Test]
+    public async Task Should_include_headers_required_by_ServicePulse()
+    {
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+
+                // This is needed for ServiceControl to be able to detect both endpoint (via failed q header) and host via the processing machine header
+                // Missing endpoint or host will cause a null ref in ServicePulse
+                c.Headers[Headers.ProcessingMachine] = "MyMachine";
+
+                c.Headers["NServiceBus.ExceptionInfo.ExceptionType"] = "SomeExceptionType";
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs
user: login: ramonsmits
body: Use `https://github.com/Particular/NServiceBus/blob/9.2.7/src/NServiceBus.Core/Recoverability/Faults/FaultsHeaderKeys.cs`
created_at: 2025-04-25T08:10:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,131 @@
+﻿namespace ServiceControl.AcceptanceTests.Recoverability.MessageFailures;
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.EndpointTemplates;
+using Infrastructure;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.Routing;
+using NServiceBus.Transport;
+using NUnit.Framework;
+using ServiceControl.MessageFailures.Api;
+
+class When_ingesting_failed_message_with_missing_headers : AcceptanceTest
+{
+    [Test]
+    public async Task Should_be_ingested_when_minimal_required_headers_is_present()
+    {
+        var testStartTime = DateTime.UtcNow;
+
+        var context = await Define<TestContext>(c => c.AddMinimalRequiredHeaders())
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.Null);
+
+        //No failure time will result in utc now being used
+        Assert.That(failure.TimeOfFailure, Is.GreaterThan(testStartTime));
+
+        // Both host and endpoint name is currently needed so this will be null since no host can be detected from the failed q header
+        Assert.That(failure.ReceivingEndpoint, Is.Null);
+    }
+
+    [Test]
+    public async Task Should_include_headers_required_by_ServicePulse()
+    {
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+
+                // This is needed for ServiceControl to be able to detect both endpoint (via failed q header) and host via the processing machine header
+                // Missing endpoint or host will cause a null ref in ServicePulse
+                c.Headers[Headers.ProcessingMachine] = "MyMachine";
+
+                c.Headers["NServiceBus.ExceptionInfo.ExceptionType"] = "SomeExceptionType";
+                c.Headers["NServiceBus.ExceptionInfo.Message"] = "Some message";
+            })
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+
+        // ServicePulse assumes that the receiving endpoint name is present
+        Assert.That(failure.ReceivingEndpoint, Is.Not.Null);
+        Assert.That(failure.ReceivingEndpoint.Name, Is.EqualTo(context.EndpointNameOfReceivingEndpoint));
+        Assert.That(failure.ReceivingEndpoint.Host, Is.EqualTo("MyMachine"));
+
+        // ServicePulse needs both an exception type and description to render the UI in a resonable way
+        Assert.That(failure.Exception.ExceptionType, Is.EqualTo("SomeExceptionType"));
+        Assert.That(failure.Exception.Message, Is.EqualTo("Some message"));
+    }
+
+    [Test]
+    public async Task TimeSent_should_not_be_casted()
+    {
+        var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z");
+
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+                c.Headers.Add("NServiceBus.TimeSent", DateTimeOffsetHelper.ToWireFormattedString(sentTime));
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs
user: login: ramonsmits
body: Use `Headers.TimeSent`
created_at: 2025-04-25T08:11:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,131 @@
+﻿namespace ServiceControl.AcceptanceTests.Recoverability.MessageFailures;
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using AcceptanceTesting;
+using AcceptanceTesting.EndpointTemplates;
+using Infrastructure;
+using NServiceBus;
+using NServiceBus.AcceptanceTesting;
+using NServiceBus.Routing;
+using NServiceBus.Transport;
+using NUnit.Framework;
+using ServiceControl.MessageFailures.Api;
+
+class When_ingesting_failed_message_with_missing_headers : AcceptanceTest
+{
+    [Test]
+    public async Task Should_be_ingested_when_minimal_required_headers_is_present()
+    {
+        var testStartTime = DateTime.UtcNow;
+
+        var context = await Define<TestContext>(c => c.AddMinimalRequiredHeaders())
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.Null);
+
+        //No failure time will result in utc now being used
+        Assert.That(failure.TimeOfFailure, Is.GreaterThan(testStartTime));
+
+        // Both host and endpoint name is currently needed so this will be null since no host can be detected from the failed q header
+        Assert.That(failure.ReceivingEndpoint, Is.Null);
+    }
+
+    [Test]
+    public async Task Should_include_headers_required_by_ServicePulse()
+    {
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+
+                // This is needed for ServiceControl to be able to detect both endpoint (via failed q header) and host via the processing machine header
+                // Missing endpoint or host will cause a null ref in ServicePulse
+                c.Headers[Headers.ProcessingMachine] = "MyMachine";
+
+                c.Headers["NServiceBus.ExceptionInfo.ExceptionType"] = "SomeExceptionType";
+                c.Headers["NServiceBus.ExceptionInfo.Message"] = "Some message";
+            })
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+
+        // ServicePulse assumes that the receiving endpoint name is present
+        Assert.That(failure.ReceivingEndpoint, Is.Not.Null);
+        Assert.That(failure.ReceivingEndpoint.Name, Is.EqualTo(context.EndpointNameOfReceivingEndpoint));
+        Assert.That(failure.ReceivingEndpoint.Host, Is.EqualTo("MyMachine"));
+
+        // ServicePulse needs both an exception type and description to render the UI in a resonable way
+        Assert.That(failure.Exception.ExceptionType, Is.EqualTo("SomeExceptionType"));
+        Assert.That(failure.Exception.Message, Is.EqualTo("Some message"));
+    }
+
+    [Test]
+    public async Task TimeSent_should_not_be_casted()
+    {
+        var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z");
+
+        var context = await Define<TestContext>(c =>
+            {
+                c.AddMinimalRequiredHeaders();
+                c.Headers.Add("NServiceBus.TimeSent", DateTimeOffsetHelper.ToWireFormattedString(sentTime));
+            })
+            .WithEndpoint<FailingEndpoint>()
+            .Done(async c => await TryGetFailureFromApi(c))
+            .Run();
+
+        var failure = context.Failure;
+
+        Assert.That(failure, Is.Not.Null);
+        Assert.That(failure.TimeSent, Is.EqualTo(sentTime));
+    }
+
+    async Task<bool> TryGetFailureFromApi(TestContext context)
+    {
+        context.Failure = await this.TryGet<FailedMessageView>($"/api/errors/last/{context.UniqueMessageId}");
+        return context.Failure != null;
+    }
+
+    class TestContext : ScenarioContext
+    {
+        public string MessageId { get; } = Guid.NewGuid().ToString();
+
+        public string EndpointNameOfReceivingEndpoint => "MyEndpoint";
+
+        public string UniqueMessageId => DeterministicGuid.MakeId(MessageId, EndpointNameOfReceivingEndpoint).ToString();
+
+        public Dictionary<string, string> Headers { get; } = [];
+
+        public FailedMessageView Failure { get; set; }
+
+        public void AddMinimalRequiredHeaders()
+        {
+            Headers["NServiceBus.FailedQ"] = EndpointNameOfReceivingEndpoint;
path: src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs
user: login: ramonsmits
body: Use `FaultsHeaderKeys.FailedQ`
created_at: 2025-04-25T08:12:28Z
author_association: MEMBER

PR comments:
user: login: ramonsmits
created_at: 2025-04-22T12:45:44Z
body: > * Requiring the `ProcessingMachine` feels off, I think we should default it to null or unknown to be more flexible when importing

Indeed, that should be optional but requires SP/SC changes.

> * MessageId header is needed for some reason, even though we have the native message id from the transport

The header might be auto generated based on the transport message ID in some transports. 

Where are you referring to? It isn't obvious in the diff from this PR.
author_association: MEMBER
user: login: andreasohlund
created_at: 2025-04-24T08:34:30Z
body: @ramonsmits I've created separate tests for what is needed for ingestion to work vs what is needed for the UI to render reasonably. See if you like it
author_association: MEMBER
user: login: andreasohlund
created_at: 2025-04-25T08:27:27Z
body: Good catch, replaced with constants now
author_association: MEMBER