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); } }