diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
index 3457e66cf9..6b43e7f94a 100644
--- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -312,7 +312,87 @@ public async Task Receiver_LogWithServiceName_RecordsSeenService()
await Assert.That(receiver.Diagnostics.LogsRecordsParsed).IsEqualTo(1);
}
- private static byte[] BuildLogsExportRequest(string serviceName, string traceId, string body)
+ [Test]
+ public async Task Parser_LogWithExceptionAttributes_ExtractsAndFormatsException()
+ {
+ var traceId = Guid.NewGuid().ToString("N");
+ const string stackTrace = "System.InvalidOperationException: boom\n at Sut.Fail()";
+ var body = BuildLogsExportRequest(
+ "my-service",
+ traceId,
+ "Could not say hello",
+ severityText: "ERROR",
+ exceptionType: "System.InvalidOperationException",
+ exceptionMessage: "boom",
+ exceptionStackTrace: stackTrace);
+
+ var records = OtlpLogParser.Parse(body);
+
+ await Assert.That(records.Count).IsEqualTo(1);
+ var record = records[0];
+ await Assert.That(record.Body).IsEqualTo("Could not say hello");
+ await Assert.That(record.ExceptionType).IsEqualTo("System.InvalidOperationException");
+ await Assert.That(record.ExceptionMessage).IsEqualTo("boom");
+ await Assert.That(record.ExceptionStackTrace).IsEqualTo(stackTrace);
+ // Full stack trace preferred over the discrete type/message fields.
+ await Assert.That(record.FormatException()).IsEqualTo(stackTrace);
+ }
+
+ [Test]
+ public async Task Parser_LogWithoutException_HasNoExceptionDetail()
+ {
+ var traceId = Guid.NewGuid().ToString("N");
+ var body = BuildLogsExportRequest("my-service", traceId, "just a log line");
+
+ var records = OtlpLogParser.Parse(body);
+
+ await Assert.That(records.Count).IsEqualTo(1);
+ var record = records[0];
+ await Assert.That(record.FormatException()).IsNull();
+ }
+
+ [Test]
+ public async Task Parser_ExceptionTypeAndMessageOnly_FormatsAsTypeColonMessage()
+ {
+ var traceId = Guid.NewGuid().ToString("N");
+ var body = BuildLogsExportRequest(
+ "my-service",
+ traceId,
+ "boom happened",
+ severityText: "ERROR",
+ exceptionType: "System.InvalidOperationException",
+ exceptionMessage: "boom");
+
+ var records = OtlpLogParser.Parse(body);
+
+ await Assert.That(records.Count).IsEqualTo(1);
+ var record = records[0];
+ // No stack trace available → fall back to "type: message".
+ await Assert.That(record.FormatException()).IsEqualTo("System.InvalidOperationException: boom");
+ }
+
+ private static void WriteExceptionAttribute(MemoryStream logRecordStream, string key, string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ // KeyValue { key (1), value = AnyValue (2) } written to LogRecord.attributes (field 6).
+ using var kvStream = new MemoryStream();
+ WriteStringField(kvStream, 1, key);
+ WriteField(kvStream, 2, BuildAnyValue(value));
+ WriteField(logRecordStream, 6, kvStream.ToArray());
+ }
+
+ private static byte[] BuildLogsExportRequest(
+ string serviceName,
+ string traceId,
+ string body,
+ string severityText = "INFO",
+ string exceptionType = "",
+ string exceptionMessage = "",
+ string exceptionStackTrace = "")
{
// KeyValue { key = "service.name", value = AnyValue(serviceName) }
using var kvStream = new MemoryStream();
@@ -323,10 +403,13 @@ private static byte[] BuildLogsExportRequest(string serviceName, string traceId,
using var resourceStream = new MemoryStream();
WriteField(resourceStream, 1, kvStream.ToArray());
- // LogRecord { severity_text (3), body (5), trace_id (9) }
+ // LogRecord { severity_text (3), body (5), attributes (6)*, trace_id (9) }
using var logRecordStream = new MemoryStream();
- WriteStringField(logRecordStream, 3, "INFO");
+ WriteStringField(logRecordStream, 3, severityText);
WriteField(logRecordStream, 5, BuildAnyValue(body));
+ WriteExceptionAttribute(logRecordStream, "exception.type", exceptionType);
+ WriteExceptionAttribute(logRecordStream, "exception.message", exceptionMessage);
+ WriteExceptionAttribute(logRecordStream, "exception.stacktrace", exceptionStackTrace);
WriteField(logRecordStream, 9, Convert.FromHexString(traceId));
// ScopeLogs { log_records (field 2) = [logRecord] }
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs b/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
index 7636ee62b9..dadd2d4325 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
@@ -13,12 +13,51 @@ namespace TUnit.OpenTelemetry.Receiver;
/// other value types (int, bool, kvlist, array) are not currently extracted.
///
/// The service.name resource attribute, if present.
+///
+/// The exception.type log attribute, if present. Populated by the OTLP log exporter
+/// (OpenTelemetry .NET 1.8.0+) whenever a log record carries an exception. Empty otherwise.
+///
+/// The exception.message log attribute, if present. Empty otherwise.
+///
+/// The exception.stacktrace log attribute, if present. In OpenTelemetry .NET this is the
+/// full Exception.ToString() (type, message, and stack), so it already subsumes the type
+/// and message fields. Empty otherwise.
+///
internal readonly record struct OtlpLogRecord(
string TraceId,
string SeverityText,
int SeverityNumber,
string Body,
- string ResourceName);
+ string ResourceName,
+ string ExceptionType = "",
+ string ExceptionMessage = "",
+ string ExceptionStackTrace = "")
+{
+ ///
+ /// Renders the exception attributes into a single human-readable block, or null when the
+ /// record carries no exception. Prefers (the full
+ /// ToString()); otherwise falls back to type: message from the discrete fields.
+ ///
+ public string? FormatException()
+ {
+ if (!string.IsNullOrEmpty(ExceptionStackTrace))
+ {
+ return ExceptionStackTrace;
+ }
+
+ if (!string.IsNullOrEmpty(ExceptionType) && !string.IsNullOrEmpty(ExceptionMessage))
+ {
+ return $"{ExceptionType}: {ExceptionMessage}";
+ }
+
+ if (!string.IsNullOrEmpty(ExceptionType))
+ {
+ return ExceptionType;
+ }
+
+ return string.IsNullOrEmpty(ExceptionMessage) ? null : ExceptionMessage;
+ }
+}
///
/// Minimal parser for OTLP ExportLogsServiceRequest protobuf messages.
@@ -154,6 +193,9 @@ private static bool ParseScopeLogs(ProtobufReader reader, string resourceName, L
var severityNumber = 0;
var severityText = "";
var body = "";
+ var exceptionType = "";
+ var exceptionMessage = "";
+ var exceptionStackTrace = "";
while (reader.TryReadTag(out var fieldNumber, out var wireType))
{
@@ -172,6 +214,31 @@ private static bool ParseScopeLogs(ProtobufReader reader, string resourceName, L
body = ParseAnyValueString(bodyMsg);
break;
+ // LogRecord.attributes (field 6) — OpenTelemetry's OTLP log exporter attaches the
+ // exception.* semantic-convention attributes here whenever a record carries an
+ // exception. Pull those three out so the exception can be surfaced alongside the body
+ // (the body alone is often just the log message, not the failure detail).
+ case 6 when wireType == WireType.LengthDelimited:
+ var (key, value) = ParseExceptionAttribute(reader.ReadEmbeddedMessage());
+ switch (key)
+ {
+ case "exception.type":
+ exceptionType = value;
+ break;
+ case "exception.message":
+ exceptionMessage = value;
+ break;
+ case "exception.stacktrace":
+ exceptionStackTrace = value;
+ break;
+ default:
+ // ParseExceptionAttribute already filters to the three exception.* keys;
+ // any other attribute arrives with an empty value and is ignored.
+ break;
+ }
+
+ break;
+
case 9 when wireType == WireType.LengthDelimited:
var traceBytes = reader.ReadBytesAsSpan();
if (traceBytes.Length == 16)
@@ -192,7 +259,15 @@ private static bool ParseScopeLogs(ProtobufReader reader, string resourceName, L
return null;
}
- return new OtlpLogRecord(traceId, severityText, severityNumber, body, resourceName);
+ return new OtlpLogRecord(
+ traceId,
+ severityText,
+ severityNumber,
+ body,
+ resourceName,
+ exceptionType,
+ exceptionMessage,
+ exceptionStackTrace);
}
private static string ParseAnyValueString(ProtobufReader reader)
@@ -211,6 +286,34 @@ private static string ParseAnyValueString(ProtobufReader reader)
return "";
}
+ // Like ParseKeyValue, but materialises the value string only for the exception.* keys the
+ // receiver renders. A log record can carry many attributes (scopes, custom fields); parsing
+ // every value — some large — just to discard it would waste allocations on the ingest hot
+ // path. Assumes key (field 1) precedes value (field 2), which holds for all known OTel encoders.
+ private static (string Key, string Value) ParseExceptionAttribute(ProtobufReader reader)
+ {
+ var key = "";
+
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ key = reader.ReadString();
+ }
+ else if (fieldNumber == 2 && wireType == WireType.LengthDelimited
+ && key is "exception.type" or "exception.message" or "exception.stacktrace")
+ {
+ return (key, ParseAnyValueString(reader.ReadEmbeddedMessage()));
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+
+ return (key, "");
+ }
+
private static (string Key, string Value) ParseKeyValue(ProtobufReader reader)
{
var key = "";
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 8689c04e2c..45a6a30546 100644
--- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -602,6 +602,16 @@ private void ProcessLogs(byte[] body)
: $"[{record.ResourceName}] ";
testContext.Output.WriteLine($"{prefix}[{severity}] {record.Body}");
+
+ // When the SUT logged an exception, the OTLP body is usually just the message
+ // template — the actual stack trace lives in the exception.* attributes. Surface it
+ // so a failing test shows *why* it failed, not only that an error was logged.
+ var exceptionDetail = record.FormatException();
+ if (exceptionDetail is not null)
+ {
+ testContext.Output.WriteLine($"{prefix}[{severity}] {exceptionDetail}");
+ }
+
Interlocked.Increment(ref _diagnostics.LogsRecordsRouted);
}
}