Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
89 changes: 86 additions & 3 deletions TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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] }
Expand Down
107 changes: 105 additions & 2 deletions TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,51 @@ 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>
/// 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 +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))
{
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 = "";
Expand Down
10 changes: 10 additions & 0 deletions TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Loading