File: Model\TelemetryExportServiceTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Google.Protobuf.Collections;
using OpenTelemetry.Proto.Logs.V1;
using OpenTelemetry.Proto.Trace.V1;
using Xunit;
using static Aspire.Tests.Shared.Telemetry.TelemetryTestHelpers;
 
namespace Aspire.Dashboard.Tests.Model;
 
public sealed class TelemetryExportServiceTests
{
    private static readonly DateTime s_testTime = new(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc);
 
    [Fact]
    public void ConvertLogsToOtlpJson_SingleLog_ReturnsCorrectStructure()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddLogs(addContext, new RepeatedField<ResourceLogs>()
        {
            new ResourceLogs
            {
                Resource = CreateResource(name: "TestService", instanceId: "instance-1"),
                ScopeLogs =
                {
                    new ScopeLogs
                    {
                        Scope = CreateScope("TestLogger"),
                        LogRecords = { CreateLogRecord(time: s_testTime, message: "Test log message", severity: OpenTelemetry.Proto.Logs.V1.SeverityNumber.Info, eventName: "TestEvent", traceId: "abcd1234abcd1234", spanId: "efgh5678", attributes: [new KeyValuePair<string, string>("custom.attr", "custom-value")]) }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var logs = repository.GetLogs(new GetLogsContext
        {
            ResourceKey = resource.ResourceKey,
            StartIndex = 0,
            Count = int.MaxValue,
            Filters = []
        });
 
        // Act
        var result = TelemetryExportService.ConvertLogsToOtlpJson(resource, logs.Items);
 
        // Assert
        Assert.NotNull(result.ResourceLogs);
        Assert.Single(result.ResourceLogs);
 
        var resourceLogs = result.ResourceLogs[0];
        Assert.NotNull(resourceLogs.Resource);
        Assert.NotNull(resourceLogs.Resource.Attributes);
        Assert.Contains(resourceLogs.Resource.Attributes, a => a.Key == OtlpResource.SERVICE_NAME && a.Value?.StringValue == "TestService");
        Assert.Contains(resourceLogs.Resource.Attributes, a => a.Key == OtlpResource.SERVICE_INSTANCE_ID && a.Value?.StringValue == "instance-1");
 
        Assert.NotNull(resourceLogs.ScopeLogs);
        Assert.Single(resourceLogs.ScopeLogs);
 
        var scopeLogs = resourceLogs.ScopeLogs[0];
        Assert.NotNull(scopeLogs.Scope);
        Assert.Equal("TestLogger", scopeLogs.Scope.Name);
 
        Assert.NotNull(scopeLogs.LogRecords);
        Assert.Single(scopeLogs.LogRecords);
 
        var logRecord = scopeLogs.LogRecords[0];
        Assert.Equal("Test log message", logRecord.Body?.StringValue);
        Assert.Equal((int)SeverityNumber.Info, logRecord.SeverityNumber);
        Assert.Equal("Information", logRecord.SeverityText);
        Assert.Equal("TestEvent", logRecord.EventName);
        Assert.Equal(OtlpHelpers.DateTimeToUnixNanoseconds(s_testTime), logRecord.TimeUnixNano);
        Assert.Equal("61626364313233346162636431323334", logRecord.TraceId); // hex of UTF-8 bytes of "abcd1234abcd1234"
        Assert.Equal("6566676835363738", logRecord.SpanId); // hex of UTF-8 bytes of "efgh5678"
        Assert.NotNull(logRecord.Attributes);
        Assert.Contains(logRecord.Attributes, a => a.Key == "custom.attr" && a.Value?.StringValue == "custom-value");
    }
 
    [Fact]
    public void ConvertLogsToOtlpJson_MultipleLogs_GroupsByScope()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddLogs(addContext, new RepeatedField<ResourceLogs>()
        {
            new ResourceLogs
            {
                Resource = CreateResource(name: "TestService", instanceId: "instance-1"),
                ScopeLogs =
                {
                    new ScopeLogs
                    {
                        Scope = CreateScope("Logger1"),
                        LogRecords =
                        {
                            CreateLogRecord(time: s_testTime, message: "Log from Logger1"),
                            CreateLogRecord(time: s_testTime.AddSeconds(1), message: "Another log from Logger1")
                        }
                    },
                    new ScopeLogs
                    {
                        Scope = CreateScope("Logger2"),
                        LogRecords = { CreateLogRecord(time: s_testTime.AddSeconds(2), message: "Log from Logger2") }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var logs = repository.GetLogs(new GetLogsContext
        {
            ResourceKey = resource.ResourceKey,
            StartIndex = 0,
            Count = int.MaxValue,
            Filters = []
        });
 
        // Act
        var result = TelemetryExportService.ConvertLogsToOtlpJson(resource, logs.Items);
 
        // Assert
        Assert.NotNull(result.ResourceLogs);
        Assert.Single(result.ResourceLogs);
 
        var resourceLogs = result.ResourceLogs[0];
        Assert.NotNull(resourceLogs.ScopeLogs);
        Assert.Equal(2, resourceLogs.ScopeLogs.Length);
 
        var logger1Scope = resourceLogs.ScopeLogs.FirstOrDefault(s => s.Scope?.Name == "Logger1");
        Assert.NotNull(logger1Scope);
        Assert.NotNull(logger1Scope.LogRecords);
        Assert.Equal(2, logger1Scope.LogRecords.Length);
 
        var logger2Scope = resourceLogs.ScopeLogs.FirstOrDefault(s => s.Scope?.Name == "Logger2");
        Assert.NotNull(logger2Scope);
        Assert.NotNull(logger2Scope.LogRecords);
        Assert.Single(logger2Scope.LogRecords);
    }
 
    [Theory]
    [InlineData(SeverityNumber.Trace, "Trace")]
    [InlineData(SeverityNumber.Trace2, "Trace")]
    [InlineData(SeverityNumber.Trace3, "Trace")]
    [InlineData(SeverityNumber.Trace4, "Trace")]
    [InlineData(SeverityNumber.Debug, "Debug")]
    [InlineData(SeverityNumber.Debug2, "Debug")]
    [InlineData(SeverityNumber.Debug3, "Debug")]
    [InlineData(SeverityNumber.Debug4, "Debug")]
    [InlineData(SeverityNumber.Info, "Information")]
    [InlineData(SeverityNumber.Info2, "Information")]
    [InlineData(SeverityNumber.Info3, "Information")]
    [InlineData(SeverityNumber.Info4, "Information")]
    [InlineData(SeverityNumber.Warn, "Warning")]
    [InlineData(SeverityNumber.Warn2, "Warning")]
    [InlineData(SeverityNumber.Warn3, "Warning")]
    [InlineData(SeverityNumber.Warn4, "Warning")]
    [InlineData(SeverityNumber.Error, "Error")]
    [InlineData(SeverityNumber.Error2, "Error")]
    [InlineData(SeverityNumber.Error3, "Error")]
    [InlineData(SeverityNumber.Error4, "Error")]
    [InlineData(SeverityNumber.Fatal, "Critical")]
    [InlineData(SeverityNumber.Fatal2, "Critical")]
    [InlineData(SeverityNumber.Fatal3, "Critical")]
    [InlineData(SeverityNumber.Fatal4, "Critical")]
    public void ConvertLogsToOtlpJson_RoundTripsSeverityNumber(SeverityNumber inputSeverity, string expectedSeverityText)
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddLogs(addContext, new RepeatedField<ResourceLogs>()
        {
            new ResourceLogs
            {
                Resource = CreateResource(),
                ScopeLogs =
                {
                    new ScopeLogs
                    {
                        Scope = CreateScope(),
                        LogRecords = { CreateLogRecord(severity: inputSeverity) }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var logs = repository.GetLogs(new GetLogsContext
        {
            ResourceKey = resource.ResourceKey,
            StartIndex = 0,
            Count = int.MaxValue,
            Filters = []
        });
 
        // Act
        var result = TelemetryExportService.ConvertLogsToOtlpJson(resource, logs.Items);
 
        // Assert
        var logRecord = result.ResourceLogs![0].ScopeLogs![0].LogRecords![0];
        // Verify exact severity number is preserved (round-trip)
        Assert.Equal((int)inputSeverity, logRecord.SeverityNumber);
        // Verify severity text is the mapped LogLevel
        Assert.Equal(expectedSeverityText, logRecord.SeverityText);
    }
 
    [Fact]
    public void ConvertTracesToOtlpJson_SingleTrace_ReturnsCorrectStructure()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddTraces(addContext, new RepeatedField<ResourceSpans>()
        {
            new ResourceSpans
            {
                Resource = CreateResource(name: "TestService", instanceId: "instance-1"),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = CreateScope("TestTracer"),
                        Spans =
                        {
                            CreateSpan(
                                traceId: "trace123456789012",
                                spanId: "span1234",
                                startTime: s_testTime,
                                endTime: s_testTime.AddSeconds(5),
                                kind: Span.Types.SpanKind.Server,
                                status: new Status { Code = Status.Types.StatusCode.Error, Message = "Something went wrong" },
                                attributes: [new KeyValuePair<string, string>("http.method", "GET")])
                        }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var traces = repository.GetTraces(new GetTracesRequest
        {
            ResourceKey = resource.ResourceKey,
            StartIndex = 0,
            Count = int.MaxValue,
            FilterText = string.Empty,
            Filters = []
        });
 
        // Act
        var result = TelemetryExportService.ConvertTracesToOtlpJson(resource, traces.PagedResult.Items);
 
        // Assert
        Assert.NotNull(result.ResourceSpans);
        Assert.Single(result.ResourceSpans);
 
        var resourceSpans = result.ResourceSpans[0];
        Assert.NotNull(resourceSpans.Resource);
        Assert.NotNull(resourceSpans.Resource.Attributes);
        Assert.Contains(resourceSpans.Resource.Attributes, a => a.Key == OtlpResource.SERVICE_NAME && a.Value?.StringValue == "TestService");
 
        Assert.NotNull(resourceSpans.ScopeSpans);
        Assert.Single(resourceSpans.ScopeSpans);
 
        var scopeSpans = resourceSpans.ScopeSpans[0];
        Assert.NotNull(scopeSpans.Scope);
        Assert.Equal("TestTracer", scopeSpans.Scope.Name);
 
        Assert.NotNull(scopeSpans.Spans);
        Assert.Single(scopeSpans.Spans);
 
        var span = scopeSpans.Spans[0];
        Assert.Equal((int)OtlpSpanKind.Server, span.Kind);
        Assert.Equal("7472616365313233343536373839303132", span.TraceId); // hex of UTF-8 bytes of "trace123456789012"
        Assert.Equal("7370616e31323334", span.SpanId); // hex of UTF-8 bytes of "span1234"
        Assert.Equal("Test span. Id: span1234", span.Name);
        Assert.Equal(OtlpHelpers.DateTimeToUnixNanoseconds(s_testTime), span.StartTimeUnixNano);
        Assert.Equal(OtlpHelpers.DateTimeToUnixNanoseconds(s_testTime.AddSeconds(5)), span.EndTimeUnixNano);
        Assert.NotNull(span.Status);
        Assert.Equal((int)Status.Types.StatusCode.Error, span.Status.Code);
        Assert.Equal("Something went wrong", span.Status.Message);
        Assert.NotNull(span.Attributes);
        Assert.Contains(span.Attributes, a => a.Key == "http.method" && a.Value?.StringValue == "GET");
    }
 
    [Fact]
    public void ConvertTracesToOtlpJson_SpanWithParent_IncludesParentSpanId()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddTraces(addContext, new RepeatedField<ResourceSpans>()
        {
            new ResourceSpans
            {
                Resource = CreateResource(),
                ScopeSpans =
                {
                    new ScopeSpans
                    {
                        Scope = CreateScope(),
                        Spans =
                        {
                            CreateSpan(traceId: "trace123456789012", spanId: "parent12", startTime: s_testTime, endTime: s_testTime.AddSeconds(10)),
                            CreateSpan(traceId: "trace123456789012", spanId: "child123", startTime: s_testTime.AddSeconds(1), endTime: s_testTime.AddSeconds(5), parentSpanId: "parent12")
                        }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var traces = repository.GetTraces(new GetTracesRequest
        {
            ResourceKey = resource.ResourceKey,
            StartIndex = 0,
            Count = int.MaxValue,
            FilterText = string.Empty,
            Filters = []
        });
 
        // Act
        var result = TelemetryExportService.ConvertTracesToOtlpJson(resource, traces.PagedResult.Items);
 
        // Assert
        var spans = result.ResourceSpans![0].ScopeSpans![0].Spans!;
        Assert.Equal(2, spans.Length);
 
        var parentSpan = spans.First(s => s.ParentSpanId is null);
        var childSpan = spans.First(s => s.ParentSpanId is not null);
 
        Assert.NotNull(childSpan.ParentSpanId);
    }
 
    [Fact]
    public void ConvertMetricsToOtlpJson_SingleInstrument_ReturnsCorrectStructure()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddMetrics(addContext, new RepeatedField<OpenTelemetry.Proto.Metrics.V1.ResourceMetrics>()
        {
            new OpenTelemetry.Proto.Metrics.V1.ResourceMetrics
            {
                Resource = CreateResource(name: "TestService", instanceId: "instance-1"),
                ScopeMetrics =
                {
                    new OpenTelemetry.Proto.Metrics.V1.ScopeMetrics
                    {
                        Scope = CreateScope("TestMeter"),
                        Metrics = { CreateSumMetric("test_counter", s_testTime) }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var instruments = repository.GetInstrumentsSummaries(resource.ResourceKey);
 
        // Act
        var result = TelemetryExportService.ConvertMetricsToOtlpJson(resource, instruments);
 
        // Assert
        Assert.NotNull(result.ResourceMetrics);
        Assert.Single(result.ResourceMetrics);
 
        var resourceMetrics = result.ResourceMetrics[0];
        Assert.NotNull(resourceMetrics.Resource);
        Assert.NotNull(resourceMetrics.Resource.Attributes);
        Assert.Contains(resourceMetrics.Resource.Attributes, a => a.Key == OtlpResource.SERVICE_NAME && a.Value?.StringValue == "TestService");
 
        Assert.NotNull(resourceMetrics.ScopeMetrics);
        Assert.Single(resourceMetrics.ScopeMetrics);
 
        var scopeMetrics = resourceMetrics.ScopeMetrics[0];
        Assert.NotNull(scopeMetrics.Scope);
        Assert.Equal("TestMeter", scopeMetrics.Scope.Name);
 
        Assert.NotNull(scopeMetrics.Metrics);
        Assert.Single(scopeMetrics.Metrics);
 
        var metric = scopeMetrics.Metrics[0];
        Assert.Equal("test_counter", metric.Name);
        Assert.Equal("Test metric description", metric.Description);
        Assert.Equal("widget", metric.Unit);
    }
 
    [Fact]
    public void ConvertMetricsToOtlpJson_MultipleInstruments_GroupsByScope()
    {
        // Arrange
        var repository = CreateRepository();
        var addContext = new AddContext();
        repository.AddMetrics(addContext, new RepeatedField<OpenTelemetry.Proto.Metrics.V1.ResourceMetrics>()
        {
            new OpenTelemetry.Proto.Metrics.V1.ResourceMetrics
            {
                Resource = CreateResource(),
                ScopeMetrics =
                {
                    new OpenTelemetry.Proto.Metrics.V1.ScopeMetrics
                    {
                        Scope = CreateScope("Meter1"),
                        Metrics =
                        {
                            CreateSumMetric("counter1", s_testTime),
                            CreateSumMetric("counter2", s_testTime)
                        }
                    },
                    new OpenTelemetry.Proto.Metrics.V1.ScopeMetrics
                    {
                        Scope = CreateScope("Meter2"),
                        Metrics = { CreateHistogramMetric("histogram1", s_testTime) }
                    }
                }
            }
        });
 
        var resources = repository.GetResources();
        var resource = resources[0];
        var instruments = repository.GetInstrumentsSummaries(resource.ResourceKey);
 
        // Act
        var result = TelemetryExportService.ConvertMetricsToOtlpJson(resource, instruments);
 
        // Assert
        Assert.NotNull(result.ResourceMetrics);
        Assert.Single(result.ResourceMetrics);
 
        var resourceMetrics = result.ResourceMetrics[0];
        Assert.NotNull(resourceMetrics.ScopeMetrics);
        Assert.Equal(2, resourceMetrics.ScopeMetrics.Length);
 
        var meter1Scope = resourceMetrics.ScopeMetrics.FirstOrDefault(s => s.Scope?.Name == "Meter1");
        Assert.NotNull(meter1Scope);
        Assert.NotNull(meter1Scope.Metrics);
        Assert.Equal(2, meter1Scope.Metrics.Length);
 
        var meter2Scope = resourceMetrics.ScopeMetrics.FirstOrDefault(s => s.Scope?.Name == "Meter2");
        Assert.NotNull(meter2Scope);
        Assert.NotNull(meter2Scope.Metrics);
        Assert.Single(meter2Scope.Metrics);
    }
}