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
4 changes: 1 addition & 3 deletions src/Aspire.Dashboard/Components/Pages/StructuredLogs.razor
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,7 @@
OnResize="@(r => _manager.SetWidthFraction(r.Orientation == Orientation.Horizontal ? r.Panel1Fraction : 1))">
<DetailsTitleTemplate>
@{
var eventName = OtlpHelpers.GetValue(context!.LogEntry.Attributes, "event.name")
?? OtlpHelpers.GetValue(context!.LogEntry.Attributes, "logrecord.event.name")
?? Loc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)];
var eventName = StructureLogsDetailsViewModel.GetEventName(context!.LogEntry, Loc);
}

<div class="pane-details-title" title="@($"{eventName} ({context!.LogEntry.Scope.Name})")">
Expand Down
93 changes: 81 additions & 12 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
@using Aspire.Dashboard.Components.Controls.Grid
@using Aspire.Dashboard.Resources
@using Aspire.Dashboard.Utils
@inject IStringLocalizer<Dashboard.Resources.TraceDetail> Loc
@inject IStringLocalizer<ControlsStrings> ControlStringsLoc

<PageTitle>
<ApplicationName
Expand All @@ -22,7 +20,7 @@
<AspirePageContentLayout
AddNewlineOnToolbar="true"
MobileToolbarButtonText="@Loc[nameof(Dashboard.Resources.TraceDetail.TraceDetailMobileToolbarButtonText)]"
IsSummaryDetailsViewOpen="@(SelectedSpan is not null)">
IsSummaryDetailsViewOpen="@(SelectedData is not null)">
<PageTitleSection>
<div class="page-header">
<h1>
Expand Down Expand Up @@ -73,16 +71,31 @@
</ToolbarSection>
<MainSection>
<SummaryDetailsView
ShowDetails="SelectedSpan is not null"
ShowDetails="SelectedData is not null"
OnDismiss="@(() => ClearSelectedSpanAsync(causedByUserAction: true))"
ViewKey="TraceDetail"
SelectedValue="@SelectedSpan"
SelectedValue="@SelectedData"
OnResize="@(r => _manager.SetWidthFraction(r.Orientation == Orientation.Horizontal ? r.Panel1Fraction : 1))">
<DetailsTitleTemplate>
@{ var shortedSpanId = OtlpHelpers.ToShortenedId(context!.Span.SpanId); }
<div class="pane-details-title" title="@($"{context!.Title} ({shortedSpanId})")">
@context!.Title
<span class="pane-details-subtext">@shortedSpanId</span>
@{
string? title = null;
string? subtitle = null;

if (context?.SpanViewModel is { } spanVm)
{
title = spanVm.Title;
subtitle = OtlpHelpers.ToShortenedId(spanVm.Span.SpanId);
}
else if (context?.LogEntryViewModel is { } logEntryVm)
{
title = StructureLogsDetailsViewModel.GetEventName(logEntryVm.LogEntry, StructuredLogsLoc);
subtitle = logEntryVm.LogEntry.Scope.Name;
}
}

<div class="pane-details-title" title="@($"{title} ({subtitle})")">
@title
<span class="pane-details-subtext">@subtitle</span>
</div>
</DetailsTitleTemplate>
<Summary>
Expand Down Expand Up @@ -203,9 +216,58 @@
</div>
</HeaderCellItemTemplate>
<ChildContent>
@{
var spanColor = @ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source));
}
<div class="ticks">
<div class="span-container" style="grid-template-columns: @context.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)% @context.Width.ToString("F2", CultureInfo.InvariantCulture)% min-content;">
<div class="span-bar" style="grid-column: 2; background: @ColorGenerator.Instance.GetColorHexByKey(GetResourceName(context.Span.Source));"></div>
<div class="span-container" style="position: relative; grid-template-columns: @context.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)% @context.Width.ToString("F2", CultureInfo.InvariantCulture)% min-content;">
<div class="span-button-container">
@foreach (var item in context.SpanLogs)
{
var buttonId = $"{context.Span.SpanId}-{item.LogEntry.InternalId}";
var eventName = StructureLogsDetailsViewModel.GetEventName(item.LogEntry, StructuredLogsLoc);
var isSelected = SelectedData?.LogEntryViewModel?.LogEntry.InternalId == item.LogEntry.InternalId;
var htmlTooltip = item.Index < 500;

<button id="@buttonId"
Copy link

Copilot AI Jul 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log entry buttons lack a visible label or aria-label, which makes them inaccessible to screen readers. Add an aria-label attribute (for example, aria-label="<event name>") to each button to provide an accessible name.

Copilot uses AI. Check for mistakes.
title="@(!htmlTooltip ? $"{eventName} - {item.LogEntry.Scope.Name}" : string.Empty)"
aria-label="@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsEntryDetails)]"
class="@($"span-log-entry-button {(isSelected ? "span-log-entry-selected" : null)}")"
data-span-color="@spanColor"
style="@($"left: {@item.LeftOffset.ToString("F2", CultureInfo.InvariantCulture)}%; --span-color: {spanColor}")"
@onclick="@(() => ToggleSpanLogsAsync(item.LogEntry))"
@onclick:stopPropagation="true">
</button>
@*
There is a performance impact to having many tooltips on the page. Limit to 500 tooltips.
The button continues to be displayed and clicking on it opens the details view.
A standard browser tooltip is displayed instead of the FluentTooltip.
*@
@if (htmlTooltip)
{
<FluentTooltip Anchor="@buttonId" MaxWidth="400px">
<div class="log-tooltip-title-container">
<span class="log-tooltip-title">@eventName</span> <span class="log-tooltip-subtitle">@item.LogEntry.Scope.Name</span><br />
</div>
<table class="log-tooltip-table">
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsLevelColumnHeader)]</td>
<td>@item.LogEntry.Severity</td>
</tr>
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsTimestampColumnHeader)]</td>
<td>@FormatHelpers.FormatTimeWithOptionalDate(TimeProvider, item.LogEntry.TimeStamp, MillisecondsDisplay.Truncated)</td>
</tr>
<tr>
<td>@StructuredLogsLoc[nameof(Dashboard.Resources.StructuredLogs.StructuredLogsMessageColumnHeader)]</td>
<td>@OtlpHelpers.TruncateString(item.LogEntry.Message, maxLength: 200)</td>
</tr>
</table>
</FluentTooltip>
}
}
</div>
<div class="span-bar" style="grid-column: 2; background: @spanColor;"></div>
<div class="span-bar-label @(context.LabelIsRight ? "span-bar-label-right" : "span-bar-label-left")">
<span class="span-bar-label-detail">@SpanWaterfallViewModel.GetTitle(context.Span, _applications)</span>
<span>@DurationFormatter.FormatDuration(context.Span.Duration)</span>
Expand Down Expand Up @@ -233,7 +295,14 @@
</GridColumnManager>
</Summary>
<Details>
<SpanDetails ViewModel="context" />
@if (context?.SpanViewModel is { } spanVm)
{
<SpanDetails ViewModel="spanVm" />
}
else if (context?.LogEntryViewModel is { } logEntryVm)
{
<StructuredLogDetails ViewModel="logEntryVm" />
}
</Details>
</SummaryDetailsView>
</MainSection>
Expand Down
64 changes: 55 additions & 9 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
using Aspire.Dashboard.Model.Otlp;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Otlp.Storage;
using Aspire.Dashboard.Resources;
using Aspire.Dashboard.Telemetry;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
using Microsoft.JSInterop;
using Aspire.Dashboard.Telemetry;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;

namespace Aspire.Dashboard.Components.Pages;

Expand Down Expand Up @@ -65,6 +67,15 @@ public partial class TraceDetail : ComponentBase, IComponentWithTelemetry, IDisp
[Inject]
public required ComponentTelemetryContextProvider TelemetryContextProvider { get; init; }

[Inject]
public required IStringLocalizer<Dashboard.Resources.TraceDetail> Loc { get; init; }

[Inject]
public required IStringLocalizer<Dashboard.Resources.StructuredLogs> StructuredLogsLoc { get; init; }

[Inject]
public required IStringLocalizer<ControlsStrings> ControlStringsLoc { get; init; }

protected override void OnInitialized()
{
TelemetryContextProvider.Initialize(TelemetryContext);
Expand Down Expand Up @@ -196,8 +207,25 @@ private void UpdateDetailViewData()
return;
}

// Get logs for the trace. Note that there isn't a limit on this query so all logs are returned.
// There is a limit on the number of logs stored by the dashboard so this is implicitly limited.
// If there are performance issues with displaying all logs then consider adding a limit to this query.
var logsContext = new GetLogsContext
{
ApplicationKey = null,
Count = int.MaxValue,
StartIndex = 0,
Filters = [new TelemetryFilter
{
Field = KnownStructuredLogFields.TraceIdField,
Condition = FilterCondition.Equals,
Value = _trace.TraceId
}]
};
var result = TelemetryRepository.GetLogs(logsContext);

Logger.LogInformation("Trace '{TraceId}' has {SpanCount} spans.", _trace.TraceId, _trace.Spans.Count);
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
_spanWaterfallViewModels = SpanWaterfallViewModel.Create(_trace, result.Items, new SpanWaterfallViewModel.TraceDetailState(OutgoingPeerResolvers.ToArray(), _collapsedSpanIds));
_maxDepth = _spanWaterfallViewModels.Max(s => s.Depth);

var apps = new HashSet<OtlpApplication>();
Expand All @@ -214,7 +242,7 @@ private void UpdateDetailViewData()

private async Task HandleAfterFilterBindAsync()
{
SelectedSpan = null;
SelectedData = null;
await InvokeAsync(StateHasChanged);

await InvokeAsync(_dataGrid.SafeRefreshDataAsync);
Expand Down Expand Up @@ -243,15 +271,15 @@ private void UpdateSubscription()
private string GetRowClass(SpanWaterfallViewModel viewModel)
{
// Test with id rather than the object reference because the data and view model objects are recreated on trace updates.
if (viewModel.Span.SpanId == SelectedSpan?.Span.SpanId)
if (viewModel.Span.SpanId == SelectedData?.SpanViewModel?.Span.SpanId)
{
return "selected-row";
}

return string.Empty;
}

public SpanDetailsViewModel? SelectedSpan { get; set; }
public TraceDetailSelectedDataViewModel? SelectedData { get; set; }

private async Task OnToggleCollapse(SpanWaterfallViewModel viewModel)
{
Expand All @@ -276,7 +304,7 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin
{
_elementIdBeforeDetailsViewOpened = buttonId;

if (SelectedSpan?.Span.SpanId == viewModel.Span.SpanId)
if (SelectedData?.SpanViewModel?.Span.SpanId == viewModel.Span.SpanId)
{
await ClearSelectedSpanAsync();
}
Expand All @@ -301,7 +329,10 @@ private async Task OnShowPropertiesAsync(SpanWaterfallViewModel viewModel, strin
Backlinks = backlinks,
};

SelectedSpan = spanDetailsViewModel;
SelectedData = new TraceDetailSelectedDataViewModel
{
SpanViewModel = spanDetailsViewModel
};
}
}

Expand All @@ -324,7 +355,7 @@ private SpanLinkViewModel CreateLinkViewModel(string traceId, string spanId, Key

private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)
{
SelectedSpan = null;
SelectedData = null;

if (_elementIdBeforeDetailsViewOpened is not null && causedByUserAction)
{
Expand All @@ -336,6 +367,21 @@ private async Task ClearSelectedSpanAsync(bool causedByUserAction = false)

private string GetResourceName(OtlpApplicationView app) => OtlpApplication.GetResourceName(app, _applications);

private async Task ToggleSpanLogsAsync(OtlpLogEntry logEntry)
{
if (SelectedData?.LogEntryViewModel?.LogEntry.InternalId == logEntry.InternalId)
{
await ClearSelectedSpanAsync();
}
else
{
SelectedData = new TraceDetailSelectedDataViewModel
{
LogEntryViewModel = new StructureLogsDetailsViewModel { LogEntry = logEntry }
};
}
}

public void Dispose()
{
foreach (var subscription in _peerChangesSubscriptions)
Expand Down
72 changes: 72 additions & 0 deletions src/Aspire.Dashboard/Components/Pages/TraceDetail.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,75 @@
display: flex;
column-gap: 8px;
}

::deep .span-button-container {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
}

::deep .span-log-entry-button {
position: absolute;
width: 15px;
height: 15px;
overflow: hidden;
border-radius: 50%;
align-self: center;
opacity: 0.8;
transform: translateX(-50%);
background: color-mix(in srgb, var(--span-color), white 30%);
border-color: color-mix(in srgb, var(--span-color), black 50%);
}

::deep .span-log-entry-button:hover {
opacity: 1;
background: color-mix(in srgb, var(--span-color), white 50%);
}

::deep .span-log-entry-selected {
opacity: 1;
background: color-mix(in srgb, var(--span-color), white 50%);
width: 20px;
height: 20px;
}

::deep .log-tooltip-title {
font-weight: bold;
font-size: 16px;
}

::deep .log-tooltip-subtitle {
color: var(--foreground-subtext-rest);
font-size: 12px;
padding-left: 0.5rem;
}

::deep.log-tooltip-title-container {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-bottom: 8px;
margin-top: 4px;
}

::deep.log-tooltip-table {
margin-bottom: 0;
width: 100%;
table-layout: fixed;
}

::deep.log-tooltip-table td:nth-child(1) {
width: 25%;
}

::deep.log-tooltip-table td {
padding: calc((var(--design-unit) + var(--focus-stroke-width) - var(--stroke-width))* 1px) 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-content: center;
border-bottom: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
}
13 changes: 13 additions & 0 deletions src/Aspire.Dashboard/Model/Otlp/SpanLogEntryViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// 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.Otlp.Model;

namespace Aspire.Dashboard.Model.Otlp;

public sealed class SpanLogEntryViewModel
{
public required int Index { get; init; }
public required OtlpLogEntry LogEntry { get; init; }
public required double LeftOffset { get; init; }
}
Loading