Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,118 @@ public async Task Receiver_LogWithServiceName_RecordsSeenService()
await Assert.That(receiver.Diagnostics.LogsRecordsParsed).IsEqualTo(1);
}

[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 = BuildLogsExportRequestWithException(
"my-service",
traceId,
"Could not say hello",
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);
await Assert.That(record.HasException).IsTrue();
// 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.HasException).IsFalse();
await Assert.That(record.FormatException()).IsNull();
}

[Test]
public async Task Parser_ExceptionTypeAndMessageOnly_FormatsAsTypeColonMessage()
{
var traceId = Guid.NewGuid().ToString("N");
var body = BuildLogsExportRequestWithException(
"my-service",
traceId,
"boom happened",
exceptionType: "System.InvalidOperationException",
exceptionMessage: "boom",
exceptionStackTrace: "");

var records = OtlpLogParser.Parse(body);

await Assert.That(records.Count).IsEqualTo(1);
var record = records[0];
await Assert.That(record.HasException).IsTrue();
// No stack trace available → fall back to "type: message".
await Assert.That(record.FormatException()).IsEqualTo("System.InvalidOperationException: boom");
}

private static byte[] BuildLogsExportRequestWithException(
string serviceName,
string traceId,
string body,
string exceptionType,
string exceptionMessage,
string exceptionStackTrace)
{
// KeyValue { key = "service.name", value = AnyValue(serviceName) }
using var kvStream = new MemoryStream();
WriteStringField(kvStream, 1, "service.name");
WriteField(kvStream, 2, BuildAnyValue(serviceName));

using var resourceStream = new MemoryStream();
WriteField(resourceStream, 1, kvStream.ToArray());

// LogRecord { severity_text (3), body (5), attributes (6)*, trace_id (9) }
using var logRecordStream = new MemoryStream();
WriteStringField(logRecordStream, 3, "ERROR");
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));

using var scopeLogsStream = new MemoryStream();
WriteField(scopeLogsStream, 2, logRecordStream.ToArray());

using var resourceLogsStream = new MemoryStream();
WriteField(resourceLogsStream, 1, resourceStream.ToArray());
WriteField(resourceLogsStream, 2, scopeLogsStream.ToArray());

using var exportStream = new MemoryStream();
WriteField(exportStream, 1, resourceLogsStream.ToArray());
return exportStream.ToArray();
}

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)
{
// KeyValue { key = "service.name", value = AnyValue(serviceName) }
Expand Down
84 changes: 82 additions & 2 deletions TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,59 @@ namespace TUnit.OpenTelemetry.Receiver;
/// other value types (int, bool, kvlist, array) are not currently extracted.
/// </param>
/// <param name="ResourceName">The <c>service.name</c> resource attribute, if present.</param>
/// <param name="ExceptionType">
/// The <c>exception.type</c> log attribute, if present. Populated by the OTLP log exporter
/// (OpenTelemetry .NET 1.8.0+) whenever a log record carries an exception. Empty otherwise.
/// </param>
/// <param name="ExceptionMessage">The <c>exception.message</c> log attribute, if present. Empty otherwise.</param>
/// <param name="ExceptionStackTrace">
/// The <c>exception.stacktrace</c> log attribute, if present. In OpenTelemetry .NET this is the
/// full <c>Exception.ToString()</c> (type, message, and stack), so it already subsumes the type
/// and message fields. Empty otherwise.
/// </param>
internal readonly record struct OtlpLogRecord(
string TraceId,
string SeverityText,
int SeverityNumber,
string Body,
string ResourceName);
string ResourceName,
string ExceptionType = "",
string ExceptionMessage = "",
string ExceptionStackTrace = "")
{
/// <summary>
/// <c>true</c> when the record carries any OTel exception semantic-convention attribute.
/// </summary>
public bool HasException =>
!string.IsNullOrEmpty(ExceptionStackTrace)
|| !string.IsNullOrEmpty(ExceptionType)
|| !string.IsNullOrEmpty(ExceptionMessage);

/// <summary>
/// Renders the exception attributes into a single human-readable block, or <c>null</c> when the
/// record carries no exception. Prefers <see cref="ExceptionStackTrace"/> (the full
/// <c>ToString()</c>); otherwise falls back to <c>type: message</c> from the discrete fields.
/// </summary>
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;
}
}

/// <summary>
/// Minimal parser for OTLP ExportLogsServiceRequest protobuf messages.
Expand Down Expand Up @@ -154,6 +201,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))
{
Expand All @@ -172,6 +222,28 @@ 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 attribute = reader.ReadEmbeddedMessage();
var (key, value) = ParseKeyValue(attribute);
switch (key)
{
case "exception.type":
exceptionType = value;
break;
case "exception.message":
exceptionMessage = value;
break;
case "exception.stacktrace":
exceptionStackTrace = value;
break;
}

break;

case 9 when wireType == WireType.LengthDelimited:
var traceBytes = reader.ReadBytesAsSpan();
if (traceBytes.Length == 16)
Expand All @@ -192,7 +264,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)
Expand Down
9 changes: 9 additions & 0 deletions TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,15 @@ 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.
if (record.HasException)
{
testContext.Output.WriteLine($"{prefix}{record.FormatException()}");
}

Interlocked.Increment(ref _diagnostics.LogsRecordsRouted);
}
}
Expand Down
Loading