| File: Model\Otlp\SpanWaterfallViewModel.cs | Web Access |
| Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Aspire.Dashboard.Otlp.Model; using Aspire.Dashboard.Otlp.Storage; namespace Aspire.Dashboard.Model.Otlp; [DebuggerDisplay("Span = {Span.SpanId}, Depth = {Depth}, Children = {Children.Count}, HasUninstrumentedPeer = {HasUninstrumentedPeer}, IsHidden = {IsHidden}")] public sealed class SpanWaterfallViewModel { public required List<SpanWaterfallViewModel> Children { get; init; } public required OtlpSpan Span { get; init; } public required double LeftOffset { get; init; } public required double Width { get; init; } public required int Depth { get; init; } public required bool LabelIsRight { get; init; } public required string? UninstrumentedPeer { get; init; } public required List<SpanLogEntryViewModel> SpanLogs { get; init; } public bool IsHidden { get; set; } [MemberNotNullWhen(true, nameof(UninstrumentedPeer))] public bool HasUninstrumentedPeer => !string.IsNullOrEmpty(UninstrumentedPeer); public bool IsError => Span.Status == OtlpSpanStatusCode.Error; public bool IsCollapsed { get; set { field = value; UpdateHidden(); } } public string GetTooltip(List<OtlpResource> allResources) { var tooltip = GetTitle(Span, allResources); if (IsError) { tooltip += Environment.NewLine + "Status = Error"; } if (HasUninstrumentedPeer) { tooltip += Environment.NewLine + $"Outgoing call to {UninstrumentedPeer}"; } return tooltip; } private void UpdateHidden(bool isParentCollapsed = false) { IsHidden = isParentCollapsed; foreach (var child in Children) { child.UpdateHidden(isParentCollapsed || IsCollapsed); } } private readonly record struct SpanWaterfallViewModelState(SpanWaterfallViewModel? Parent, int Depth, bool Hidden); public sealed record TraceDetailState(List<string> CollapsedSpanIds, List<OtlpResource> AllResources); public static string GetTitle(OtlpSpan span, List<OtlpResource> allResources) { return $"{OtlpResource.GetResourceName(span.Source, allResources)}: {span.GetDisplaySummary()}"; } public static List<SpanWaterfallViewModel> Create(OtlpTrace trace, List<LogSummary> logs, TraceDetailState state) { var orderedSpans = new List<SpanWaterfallViewModel>(); var groupedLogs = logs.GroupBy(l => l.SpanId).ToDictionary(l => l.Key, g => g.ToList()); var currentSpanLogIndex = 0; TraceHelpers.VisitSpans(trace, (OtlpSpan span, SpanWaterfallViewModelState s) => { groupedLogs.TryGetValue(span.SpanId, out var spanLogs); var viewModel = CreateViewModel(span, s.Depth, s.Hidden, state, spanLogs, ref currentSpanLogIndex); orderedSpans.Add(viewModel); s.Parent?.Children.Add(viewModel); return s with { Parent = viewModel, Depth = s.Depth + 1, Hidden = viewModel.IsHidden || viewModel.IsCollapsed }; }, new SpanWaterfallViewModelState(Parent: null, Depth: 1, Hidden: false)); return orderedSpans; static SpanWaterfallViewModel CreateViewModel(OtlpSpan span, int depth, bool hidden, TraceDetailState state, List<LogSummary>? spanLogs, ref int currentSpanLogIndex) { var traceStart = span.Trace.FirstSpan.StartTime; var relativeStart = span.StartTime - traceStart; var rootDuration = span.Trace.Duration.TotalMilliseconds; 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. var labelIsRight = (relativeStart + span.Duration / 2) < (span.Trace.Duration / 2); // A span may indicate a call to another service but the service isn't instrumented. var hasPeerService = OtlpHelpers.GetPeerAddress(span.Attributes) != null; var isUninstrumentedPeer = hasPeerService && span.Kind is OtlpSpanKind.Client or OtlpSpanKind.Producer && !span.GetChildSpans().Any(); var uninstrumentedPeer = isUninstrumentedPeer ? ResolveUninstrumentedPeerName(span, state.AllResources) : null; var spanLogVms = new List<SpanLogEntryViewModel>(); if (spanLogs != null) { foreach (var log in spanLogs) { var logRelativeStart = log.TimeStamp - traceStart; spanLogVms.Add(new SpanLogEntryViewModel { Index = currentSpanLogIndex++, LogEntry = log, LeftOffset = CalculatePercent(logRelativeStart.TotalMilliseconds, rootDuration) }); } } var viewModel = new SpanWaterfallViewModel { Children = [], Span = span, LeftOffset = leftOffset, Width = width, Depth = depth, LabelIsRight = labelIsRight, UninstrumentedPeer = uninstrumentedPeer, SpanLogs = spanLogVms }; // Restore hidden/collapsed state to new view model. if (state.CollapsedSpanIds.Contains(span.SpanId)) { viewModel.IsCollapsed = true; } if (hidden) { viewModel.IsHidden = true; } return viewModel; } static double CalculatePercent(double value, double total) { if (total == 0) { return 0; } return value / total * 100; } } private static string? ResolveUninstrumentedPeerName(OtlpSpan span, List<OtlpResource> allResources) { if (span.UninstrumentedPeer != null) { // If the span has a peer name, use it. Note that when the peer is a resource with replicas, it's possible the uninstrumented peer name returned here isn't the real replica. // We are matching an address to replicas which share the same address. There isn't a way to know exactly which replica was called. The first replica instance will be chosen. // This shouldn't be a big issue because typically project replicas will have OTEL setup, and so a child span is recorded. return OtlpHelpers.GetResourceName(span.UninstrumentedPeer, allResources); } // Fallback to the peer address. return OtlpHelpers.GetPeerAddress(span.Attributes); } }