diff --git a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs index 10fabcdb77a..2fbc725c7c7 100644 --- a/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs +++ b/src/Aspire.Dashboard/Model/Otlp/SpanWaterfallViewModel.cs @@ -140,8 +140,8 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid var relativeStart = span.StartTime - traceStart; var rootDuration = span.Trace.Duration.TotalMilliseconds; - var leftOffset = relativeStart.TotalMilliseconds / rootDuration * 100; - var width = span.Duration.TotalMilliseconds / rootDuration * 100; + var leftOffset = CalculatePercent(relativeStart.TotalMilliseconds, rootDuration); + var width = CalculatePercent(span.Duration.TotalMilliseconds, rootDuration); // Figure out if the label is displayed to the left or right of the span. // If the label position is based on whether more than half of the span is on the left or right side of the trace. @@ -163,7 +163,7 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid { Index = currentSpanLogIndex++, LogEntry = log, - LeftOffset = logRelativeStart.TotalMilliseconds / rootDuration * 100 + LeftOffset = CalculatePercent(logRelativeStart.TotalMilliseconds, rootDuration) }); } } @@ -192,6 +192,15 @@ static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hid return viewModel; } + + static double CalculatePercent(double value, double total) + { + if (total == 0) + { + return 0; + } + return value / total * 100; + } } private static string? ResolveUninstrumentedPeerName(OtlpSpan span, IOutgoingPeerResolver[] outgoingPeerResolvers) diff --git a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs index 34ede459094..a31f3928331 100644 --- a/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs +++ b/tests/Aspire.Dashboard.Tests/Model/SpanWaterfallViewModelTests.cs @@ -5,7 +5,9 @@ using Aspire.Dashboard.Model.Otlp; using Aspire.Dashboard.Otlp.Model; using Aspire.Tests.Shared.Telemetry; +using Google.Protobuf.Collections; using Microsoft.Extensions.Logging.Abstractions; +using OpenTelemetry.Proto.Common.V1; using Xunit; namespace Aspire.Dashboard.Tests.Model; @@ -42,6 +44,36 @@ public void Create_HasChildren_ChildrenPopulated() }); } + [Fact] + public void Create_RootSpanZeroDuration_ZeroPercentage() + { + // Arrange + var context = new OtlpContext { Logger = NullLogger.Instance, Options = new() }; + var app1 = new OtlpApplication("app1", "instance", uninstrumentedPeer: false, context); + var app1View = new OtlpApplicationView(app1, new RepeatedField()); + + var date = new DateTime(2001, 1, 1, 1, 1, 2, DateTimeKind.Utc); + var trace = new OtlpTrace(new byte[] { 1, 2, 3 }); + var scope = TelemetryTestHelpers.CreateOtlpScope(context); + trace.AddSpan(TelemetryTestHelpers.CreateOtlpSpan(app1, trace, scope, spanId: "31", parentSpanId: null, startDate: date, endDate: date)); + var log = new OtlpLogEntry(TelemetryTestHelpers.CreateLogRecord(traceId: trace.TraceId, spanId: "1"), app1View, scope, context); + + // Act + var vm = SpanWaterfallViewModel.Create(trace, [log], new SpanWaterfallViewModel.TraceDetailState([], [])); + + // Assert + Assert.Collection(vm, + e => + { + Assert.Equal("31", e.Span.SpanId); + Assert.Equal(0, e.LeftOffset); + Assert.Equal(0, e.Width); + + var spanLog = Assert.Single(e.SpanLogs); + Assert.Equal(0, spanLog.LeftOffset); + }); + } + [Fact] public void Create_OutgoingPeers_BrowserLink() { diff --git a/tests/Shared/Telemetry/TelemetryTestHelpers.cs b/tests/Shared/Telemetry/TelemetryTestHelpers.cs index 04846a40bea..fe85b03c702 100644 --- a/tests/Shared/Telemetry/TelemetryTestHelpers.cs +++ b/tests/Shared/Telemetry/TelemetryTestHelpers.cs @@ -184,15 +184,16 @@ public static Span CreateSpan(string traceId, string spanId, DateTime startTime, return span; } - public static LogRecord CreateLogRecord(DateTime? time = null, DateTime? observedTime = null, string? message = null, SeverityNumber? severity = null, IEnumerable>? attributes = null, bool? skipBody = null) + public static LogRecord CreateLogRecord(DateTime? time = null, DateTime? observedTime = null, string? message = null, SeverityNumber? severity = null, IEnumerable>? attributes = null, + bool? skipBody = null, string? traceId = null, string? spanId = null) { attributes ??= [new KeyValuePair("{OriginalFormat}", "Test {Log}"), new KeyValuePair("Log", "Value!")]; var logRecord = new LogRecord { Body = (skipBody ?? false) ? null : new AnyValue { StringValue = message ?? "Test Value!" }, - TraceId = ByteString.CopyFrom(Convert.FromHexString("5465737454726163654964")), - SpanId = ByteString.CopyFrom(Convert.FromHexString("546573745370616e4964")), + TraceId = (traceId != null) ? ByteString.CopyFrom(Encoding.UTF8.GetBytes(traceId)) : ByteString.CopyFrom(Convert.FromHexString("5465737454726163654964")), + SpanId = (spanId != null) ? ByteString.CopyFrom(Encoding.UTF8.GetBytes(spanId)) : ByteString.CopyFrom(Convert.FromHexString("546573745370616e4964")), TimeUnixNano = time != null ? DateTimeToUnixNanoseconds(time.Value) : 1000, ObservedTimeUnixNano = observedTime != null ? DateTimeToUnixNanoseconds(observedTime.Value) : 1000, SeverityNumber = severity ?? SeverityNumber.Info @@ -303,13 +304,13 @@ public static OtlpContext CreateContext(TelemetryLimitOptions? options = null, I public static OtlpSpan CreateOtlpSpan(OtlpApplication app, OtlpTrace trace, OtlpScope scope, string spanId, string? parentSpanId, DateTime startDate, KeyValuePair[]? attributes = null, OtlpSpanStatusCode? statusCode = null, string? statusMessage = null, OtlpSpanKind kind = OtlpSpanKind.Unspecified, - OtlpApplication? uninstrumentedPeer = null) + OtlpApplication? uninstrumentedPeer = null, DateTime? endDate = null) { return new OtlpSpan(app.GetView([]), trace, scope) { Attributes = attributes ?? [], BackLinks = [], - EndTime = DateTime.MaxValue, + EndTime = endDate ?? DateTime.MaxValue, Events = [], Kind = kind, Links = [],